Compare commits

..
Author SHA1 Message Date
Brandon Thomas d1f23bc3fe cursor behavior fixes 2026-07-12 15:28:13 -04:00
278 changed files with 2747 additions and 20758 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/windows) - MySQL/Redis binaries, data, logs
.devstack/
Generated
+1 -31
View File
@@ -34,29 +34,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "actix-files"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b"
dependencies = [
"actix-http",
"actix-service",
"actix-utils",
"actix-web",
"bitflags 2.11.0",
"bytes",
"derive_more 2.0.1",
"futures-core",
"http-range",
"log",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"v_htmlescape",
]
[[package]]
name = "actix-http"
version = "3.12.1"
@@ -7696,7 +7673,7 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -11600,7 +11577,6 @@ checksum = "ef5430c8e36b713e13b48a9f709cc21e046723fe44ce34587b73a830203b533e"
name = "storyteller-web"
version = "0.0.1"
dependencies = [
"actix-files",
"actix-http",
"actix-multipart",
"actix-service",
@@ -13918,12 +13894,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "v_htmlescape"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
[[package]]
name = "value-bag"
version = "1.12.0"
-1
View File
@@ -307,7 +307,6 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
actix-cors = "0.7.1"
actix-files = "0.6.9"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
+1 -1
View File
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Character Posing** | ![Video](https://github.com/user-attachments/assets/52a8e983-7c8f-42d2-be8b-25296ab9ed57) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
-294
View File
@@ -1,294 +0,0 @@
# Local Dev Stack — Requirements & Bootstrap
How to run the full ArtCraft dev stack **entirely locally** — the
`storyteller-web` backend plus the `artcraft-webapp` frontend — with no traffic
to production servers. Scope: webapp-first; the Tauri desktop app is a later
phase.
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)
cd frontend; npx nx dev artcraft-webapp # terminal 2: 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 && npx nx dev artcraft-webapp # terminal 2: webapp on http://localhost: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
dummy 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. `DEV_FAKE_GENERATION` (below) exists so local
submits never reach a real provider.
- **Uploads would fail hard with dummy S3 creds** (every media upload
endpoint uploads to the bucket *before* inserting the DB row, and `rust-s3`
is built with `fail-on-err`) — which is why `LOCAL_MEDIA_ROOT` (below) also
switches the bucket clients into a local filesystem mode: uploads write
under `.devstack/media/` and are immediately viewable via the same `/media`
mount. No S3/R2/MinIO needed.
### Fully-local generation and media (`DEV_FAKE_GENERATION`)
The generated secrets file enables three development-only settings (appended
automatically on bootstrap re-runs for older stacks):
| Env var | Effect |
|-----------------------|----------------------------------------------------------------------------|
| `DEV_FAKE_GENERATION` | omni_gen image/video submits skip the external provider and insert a normal **pending** job with a `fake_…` external id. A resolver thread inside `storyteller-web` completes each one after `DEV_FAKE_GENERATION_RESOLVE_SECS` (default 6s, poll every `DEV_FAKE_GENERATION_POLL_SECS`, default 2s) with a placeholder result copied from `test_data/`. Prompts containing `simulate_artcraft_failure` (or `test_artcraft_failure`) fail instead, optionally with a `FrontendFailureCategory` word in the prompt (e.g. `simulate_artcraft_failure face_not_detected`). Hard-disabled outside `SERVER_ENVIRONMENT=Development` |
| `CDN_BASE_URL` | Overrides the compiled-in CDN hosts in `cdn_link.rs` for all media URLs in API responses. Must be scheme+host only (no path) — set to `http://localhost:12345` so media resolves against the backend itself |
| `LOCAL_MEDIA_ROOT` | Directory the backend serves at `/media` (dev-only actix-files mount), mirroring the public bucket layout: object path `/media/{…}` lives at `{LOCAL_MEDIA_ROOT}/media/{…}`. Also switches all three `LegacyBucketClient`s (public/private/gc) into a **local filesystem mode**, so every media upload endpoint writes here instead of S3/R2 — uploads work with dummy cloud creds. The bootstrap points it at `.devstack/media/` |
With all three set, the full loop works offline: submit → pending job →
polling (`/v1/jobs/session`) → success with a `media_files` row whose URL the
backend itself serves — or a failed job with a real failure category. Credits
are genuinely deducted from the seeded wallet (no refund on fake results —
the demo wallet is large enough not to care).
### 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: `seed_demo_user` also seeds a handful of `media_files` image
rows (from `test_data/image/`) with their objects copied under
`.devstack/media/`, so the library isn't empty on a fresh stack. Rows mirror
what a real image upload writes (`media_class='image'` matters — the
library filters on it, and the column default `'unknown'` is invisible in
every tab).
## 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.
- **In dev the webapp already targets the local backend**: `src/main.tsx`
calls `StorytellerApiHostStore.setDevelopment()` under
`import.meta.env.DEV`, pointing all API traffic at
`http://localhost:12345`. (The adjacent comment claiming this is disabled is
stale — the call is live, and it bypasses the Vite `/v1` →
`api.storyteller.ai` proxy.) So: local backend running → fully local stack;
local backend **not** running → dev webapp is broken, not silently on prod.
- 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)
Cleaning these up is the next phase of the infra work; the bootstrap does not
change runtime behavior:
| Seam | Today |
|-----------------------------------------------|--------------------------------------------------------------|
| Media/CDN URLs in API responses | **Closed** when `CDN_BASE_URL` + `LOCAL_MEDIA_ROOT` are set (the bootstrap default): media URLs point at the backend's own `/media` mount. Unset, the compiled-in `cdn_link.rs` constants still point at the public `pub-….r2.dev` bucket |
| Media uploads | **Closed** when `LOCAL_MEDIA_ROOT` is set: the bucket clients run in local filesystem mode and uploads land under `.devstack/media/`. Unset (or non-Development), uploads still 500 with dummy creds |
| Vite `/v1` proxy in webapp | **Closed**: now targets `http://localhost:12345` (it was already bypassed in dev by `setDevelopment()`, but the fallback could silently hit production). The `/__cdn` dev proxy also no longer intercepts localhost media (it assumed https and broke pagescene GLB/splat loads against a local backend) |
| `GetCdnOrigin` (pagescene asset thumbnails) | **Closed** for local dev: returns the API host when it's localhost (the local backend serves media itself), production CDN otherwise; the pagescene adapter also skips the Cloudflare `/cdn-cgi/image` resize path on localhost. `enums/Api.ts` `API_TARGETS` and `SegmentationApi` still hardcode production hosts but are dead code (not exported/reachable from the webapp); moodboard never had a hardcoded host — it consumes `media_links` directly |
| 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`).
- Webapp at `http://localhost:4201` can log in as `localdev1`, has credits in
the top bar, and sees the seeded images in the library.
- A generation submit returns `{"success": true, "inference_job_token": …}`,
shows as pending for a few seconds, then completes with a placeholder
result (`DEV_FAKE_GENERATION`) — no external provider is contacted. A
prompt containing `simulate_artcraft_failure` fails with a real error
toast/category instead.
- Uploading an image (e.g. a reference image in the prompt box) succeeds and
the file lands under `.devstack/media/` — no S3/R2 credentials involved.
- Nothing hits `api.storyteller.ai` or the r2.dev CDN during the above
(verify in the browser network tab — media URLs should point at
`http://localhost:12345/media/…`).
## 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`.
@@ -41,8 +41,6 @@ pub enum RouterImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -75,9 +73,4 @@ mod tests {
fn midjourney_8_serializes_to_midjourney_8() {
assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8");
}
#[test]
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
}
}
@@ -26,7 +26,6 @@ use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles:
use crate::generate::generate_image::providers::artcraft::seedream_4::build::build_artcraft_seedream_4;
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
@@ -45,7 +44,6 @@ use crate::generate::generate_image::providers::fal::seedream_5_lite::build::bui
use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji;
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::build::build_kinovi_seedream_5p0_pro;
#[derive(Clone, Debug)]
pub struct GenerateImageRequestBuilder {
@@ -121,7 +119,6 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream4) => build_artcraft_seedream_4(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
@@ -144,11 +141,10 @@ 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)
// Kinovi / Seedance2Pro (Midjourney)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -9,14 +9,12 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
/// Wrapper for all image generation draft requests.
///
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only the Kinovi models need this — when the caller
/// be sent. Today only Kinovi/Midjourney needs this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
@@ -24,7 +22,6 @@ pub enum ImageGenerationDraftRequest {
KinoviMidjourney7(KinoviMidjourney7DraftState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
KinoviMidjourney8(KinoviMidjourney8DraftState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState),
}
impl ImageGenerationDraftRequest {
@@ -33,7 +30,6 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -42,7 +38,6 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
Self::KinoviSeedream5p0Pro(draft) => Ok(KinoviSeedream5p0ProCostState::from_draft(draft).estimate_cost()),
}
}
@@ -60,10 +55,6 @@ impl ImageGenerationDraftRequest {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney8(result))
}
Self::KinoviSeedream5p0Pro(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviSeedream5p0Pro(result))
}
}
}
}
@@ -39,8 +39,6 @@ use crate::generate::generate_image::providers::artcraft::seedream_4p5::cost::Ar
use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost::ArtcraftSeedream5LiteCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
@@ -77,8 +75,6 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Clone, Debug)]
pub enum ImageGenerationRequest {
@@ -96,7 +92,6 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -120,11 +115,10 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState),
}
impl ImageGenerationRequest {
@@ -143,7 +137,6 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -169,7 +162,6 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -215,9 +207,6 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(request) => {
Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -254,7 +243,6 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()),
Self::KinoviSeedream5p0Pro(request) => Ok(KinoviSeedream5p0ProCostState::from_request(request).estimate_cost()),
}
}
@@ -313,10 +301,6 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0Pro(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -405,7 +389,7 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
// ── Kinovi / Seedance2Pro (Midjourney) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
@@ -421,11 +405,6 @@ impl ImageGenerationRequest {
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
}
}
}
@@ -19,4 +19,3 @@ pub(crate) mod request_common;
pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
@@ -1,17 +0,0 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
pub fn build_artcraft_seedream_5p0_pro(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0Pro)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState { request }),
))
}
@@ -1,187 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 7;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 12;
/// Cost state for Artcraft Seedream 5.0 Pro. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 7¢, 2K → 12¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (7¢) ──
#[test]
fn default_resolution_one_image_is_7c() { assert_eq!(cost_cents(None, 1), 7); }
#[test]
fn one_k_one_image_is_7c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 7); }
#[test]
fn one_k_four_images_is_28c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 28); }
// ── 2K (12¢) ──
#[test]
fn two_k_one_image_is_12c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 12); }
#[test]
fn two_k_four_images_is_48c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 48); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(12),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(7),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,17 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -1,7 +1,6 @@
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub mod seedream_5p0_pro;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -1,408 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::{
KinoviSeedream5p0ProDraftState, KinoviSeedream5p0ProRemainingItems,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
pub fn build_kinovi_seedream_5p0_pro(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
validate_reference_image_count(image_inputs.as_ref())?;
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateSeedream5p0ProRequest {
prompt,
aspect_ratio,
resolution,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState { request }),
));
}
let draft = KinoviSeedream5p0ProDraftState {
prompt,
aspect_ratio,
resolution,
batch_count,
unhandled_request_state: Some(KinoviSeedream5p0ProRemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
/// Reject over-limit reference lists before any uploads happen — the
/// Seedance2Pro client would reject them anyway, but only after the draft
/// stage has already uploaded every image to the Kinovi CDN.
fn validate_reference_image_count(
image_inputs: Option<&ImageListRef>,
) -> Result<(), ArtcraftRouterError> {
let count = match image_inputs {
None => 0,
Some(ImageListRef::Urls(urls)) => urls.len(),
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
};
if count > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("{} reference images (max {})", count, MAX_REFERENCE_IMAGES),
}));
}
Ok(())
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProAspectRatio, ArtcraftRouterError> {
use KinoviSeedream5p0ProAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Auto),
},
}
}
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProResolution, ArtcraftRouterError> {
use KinoviSeedream5p0ProResolution as Res;
match resolution {
None | Some(RouterResolution::OneK) => Ok(Res::OneK),
Some(RouterResolution::TwoK) => Ok(Res::TwoK),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
// Snap to the nearest supported tier: sub-1k inputs (0.5K, legacy
// video resolutions) land on 1k; 3K/4K land on 2k.
_ => match unsupported {
RouterResolution::ThreeK | RouterResolution::FourK => Ok(Res::TwoK),
_ => Ok(Res::OneK),
},
},
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviSeedream5p0ProBatchCount::One),
2 => Ok(KinoviSeedream5p0ProBatchCount::Two),
3 => Ok(KinoviSeedream5p0ProBatchCount::Three),
4 => Ok(KinoviSeedream5p0ProBatchCount::Four),
5 => Ok(KinoviSeedream5p0ProBatchCount::Five),
6 => Ok(KinoviSeedream5p0ProBatchCount::Six),
7 => Ok(KinoviSeedream5p0ProBatchCount::Seven),
8 => Ok(KinoviSeedream5p0ProBatchCount::Eight),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
// Kinovi supports batches of 1–8; only counts of 9+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviSeedream5p0ProBatchCount::Eight)
}
},
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn build_request(builder: GenerateImageRequestBuilder) -> KinoviSeedream5p0ProRequestState {
match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected Request"),
}
}
mod routing_tests {
use super::*;
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_seedream_5p0_pro(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_seedream_5p0_pro(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn media_file_tokens_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn fourteen_reference_images_is_accepted() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_ok());
}
#[test]
fn fifteen_reference_images_is_rejected() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES + 1)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_err());
}
}
mod plan_tests {
use super::*;
#[test]
fn default_aspect_ratio_is_auto() {
let req = build_request(base_builder());
assert_eq!(req.request.aspect_ratio, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn every_supported_aspect_ratio_maps() {
let cases = [
(RouterAspectRatio::Auto, KinoviSeedream5p0ProAspectRatio::Auto),
(RouterAspectRatio::Square, KinoviSeedream5p0ProAspectRatio::Square1x1),
(RouterAspectRatio::WideFourByThree, KinoviSeedream5p0ProAspectRatio::Standard4x3),
(RouterAspectRatio::TallThreeByFour, KinoviSeedream5p0ProAspectRatio::Portrait3x4),
(RouterAspectRatio::WideSixteenByNine, KinoviSeedream5p0ProAspectRatio::Landscape16x9),
(RouterAspectRatio::TallNineBySixteen, KinoviSeedream5p0ProAspectRatio::Portrait9x16),
(RouterAspectRatio::WideThreeByTwo, KinoviSeedream5p0ProAspectRatio::Wide3x2),
(RouterAspectRatio::TallTwoByThree, KinoviSeedream5p0ProAspectRatio::Tall2x3),
(RouterAspectRatio::WideTwentyOneByNine, KinoviSeedream5p0ProAspectRatio::UltraWide21x9),
];
for (input, expected) in cases {
let planned = plan_aspect_ratio(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={:?}", input);
}
}
#[test]
fn unsupported_aspect_ratio_errors_out() {
let result = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
let planned = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn default_resolution_is_1k() {
let req = build_request(base_builder());
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn two_k_resolution_maps() {
let builder = GenerateImageRequestBuilder {
resolution: Some(RouterResolution::TwoK),
..base_builder()
};
let req = build_request(builder);
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn four_k_resolution_errors_out() {
let result = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn four_k_resolution_downgrades_to_2k() {
let planned = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn half_k_resolution_snaps_to_1k() {
let planned = plan_resolution(
Some(RouterResolution::HalfK),
RequestMismatchMitigationStrategy::PayMoreUpgrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn batch_counts_one_through_eight_are_accepted() {
let cases = [
(1, KinoviSeedream5p0ProBatchCount::One),
(2, KinoviSeedream5p0ProBatchCount::Two),
(3, KinoviSeedream5p0ProBatchCount::Three),
(4, KinoviSeedream5p0ProBatchCount::Four),
(5, KinoviSeedream5p0ProBatchCount::Five),
(6, KinoviSeedream5p0ProBatchCount::Six),
(7, KinoviSeedream5p0ProBatchCount::Seven),
(8, KinoviSeedream5p0ProBatchCount::Eight),
];
for (input, expected) in cases {
let planned = plan_batch_count(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={}", input);
}
}
#[test]
fn batch_count_zero_is_rejected() {
assert!(plan_batch_count(Some(0), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_errors_out() {
assert!(plan_batch_count(Some(9), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_clamps_to_eight() {
let planned = plan_batch_count(Some(9), RequestMismatchMitigationStrategy::PayLessDowngrade)
.expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProBatchCount::Eight);
}
}
}
@@ -1,100 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
/// Seedream 5.0 Pro (via Kinovi) cost state. Pricing is per image and
/// resolution-dependent (1k vs 2k); batch count multiplies.
pub struct KinoviSeedream5p0ProCostState {
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
}
impl KinoviSeedream5p0ProCostState {
pub fn from_request(request: &KinoviSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
batch_count: request.request.batch_count,
}
}
pub fn from_draft(draft: &KinoviSeedream5p0ProDraftState) -> Self {
Self {
resolution: draft.resolution,
batch_count: draft.batch_count,
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateSeedream5p0ProRequest {
prompt: String::new(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
ImageGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits_and_six_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(14));
assert_eq!(cost.cost_in_usd_cents, Some(6)); // 1400/243 = 5.76 -> rounds UP
}
#[test]
fn one_2k_image_is_twentyfour_credits_and_ten_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(96));
assert_eq!(cost.cost_in_usd_cents, Some(40)); // 9600/243 = 39.51 -> rounds UP
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(112));
assert_eq!(cost.cost_in_usd_cents, Some(47)); // 11200/243 = 46.09 -> rounds UP
}
}
@@ -1,53 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProDraftState {
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
pub unhandled_request_state: Option<KinoviSeedream5p0ProRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviSeedream5p0ProDraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviSeedream5p0ProRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateSeedream5p0ProRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviSeedream5p0ProRequestState { request })
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -1,141 +0,0 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRequestState {
pub request: GenerateSeedream5p0ProRequest,
}
impl KinoviSeedream5p0ProRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateSeedream5p0ProArgs {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_seedream_5p0_pro(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()),
image_inputs: None,
resolution: Some(RouterResolution::OneK),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno_seedream5".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("The dog in @Image1 wearing a tiny crown in a royal palace".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -1,5 +1,5 @@
use std::error::Error;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::time::Duration;
use anyhow::anyhow;
@@ -18,26 +18,11 @@ use errors::AnyhowResult;
#[derive(Clone)]
pub struct LegacyBucketClient {
backend: LegacyBucketBackend,
bucket: Bucket,
/// If set, put all files under this root path.
optional_bucket_root: Option<String>,
}
#[derive(Clone)]
enum LegacyBucketBackend {
S3 {
bucket: Bucket,
},
/// Development-only: objects live on the local filesystem under `root`,
/// with the rooted object path (e.g. "/media/a/b/…/file.jpg") mapped
/// segment-by-segment beneath it — the same layout the dev server's
/// static /media mount serves. Lets the local dev stack run every upload
/// endpoint without S3/R2 credentials.
LocalDir {
root: PathBuf,
bucket_name: String,
},
}
#[derive(Debug)]
pub enum LegacyBucketClientError {
@@ -55,6 +40,10 @@ impl Error for LegacyBucketClientError {}
impl LegacyBucketClient {
pub fn bucket_name(&self) -> String {
self.bucket.name().to_string()
}
pub fn create(
access_key: &str,
secret_key: &str,
@@ -96,34 +85,15 @@ impl LegacyBucketClient {
let optional_bucket_root = optional_bucket_root.map(|s| s.to_string());
Ok(Self {
backend: LegacyBucketBackend::S3 {
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
},
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
optional_bucket_root,
})
}
/// Development-only: a "bucket" backed by a local directory (see
/// `LegacyBucketBackend::LocalDir`). `bucket_name` is only reported back
/// via `bucket_name()`; no cloud service is ever contacted.
pub fn create_local(
local_root: impl Into<PathBuf>,
bucket_name: &str,
optional_bucket_root: Option<&str>,
) -> Self {
Self {
backend: LegacyBucketBackend::LocalDir {
root: local_root.into(),
bucket_name: bucket_name.to_string(),
},
optional_bucket_root: optional_bucket_root.map(|s| s.to_string()),
}
}
pub fn bucket_name(&self) -> String {
match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket.name().to_string(),
LegacyBucketBackend::LocalDir { bucket_name, .. } => bucket_name.clone(),
fn get_rooted_object_name(&self, object_name: &str) -> String {
match &self.optional_bucket_root {
None => object_name.to_string(),
Some(root) => format!("{}/{}", root, object_name),
}
}
@@ -133,14 +103,7 @@ impl LegacyBucketClient {
let object_name = self.get_rooted_object_name(object_name);
debug!("Rooted filename for bucket: {}", object_name);
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object(&object_name, bytes).await?;
let response = self.bucket.put_object(&object_name, bytes).await?;
let body_bytes = response.bytes();
let code = response.status_code();
@@ -161,17 +124,7 @@ impl LegacyBucketClient {
info!("Filename for bucket: {}", object_name);
let object_name = self.get_rooted_object_name(object_name);
info!("Rooted filename for bucket: {}", object_name);
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
// Content type is derived from the file extension when the dev
// static mount serves it back; nothing to store here.
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
let body_bytes = response.bytes();
let code = response.status_code();
info!("upload code: {}", code);
@@ -192,17 +145,10 @@ impl LegacyBucketClient {
let object_name = self.get_rooted_object_name(object_name);
info!("Rooted filename for bucket: {}", object_name);
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
if let Err(err) = &response {
error!("S3 Upload Error for bucket name {}: {:?}", bucket.name, err);
error!("S3 Upload Error for bucket name {}: {:?}", &self.bucket.name, err);
}
let response = response?;
@@ -258,23 +204,13 @@ impl LegacyBucketClient {
info!("Uploading with content type...");
#[allow(deprecated)]
self.upload_file_with_content_type(&object_path_str, &buffer, content_type).await
}
pub async fn download_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
info!("downloading from bucket: {}", path);
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
let disk_path = local_object_disk_path(root, path)?;
return tokio::fs::read(&disk_path).await
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err));
},
};
let response = bucket.get_object(path).await?;
let response = self.bucket.get_object(path).await?;
let bytes = response.bytes();
let code = response.status_code();
@@ -298,34 +234,24 @@ impl LegacyBucketClient {
.map(|s| s.to_string())
.ok_or(anyhow!("could not convert object path to string"))?;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
let disk_path = local_object_disk_path(root, &object_path_str)?;
tokio::fs::copy(&disk_path, filesystem_path.as_ref()).await
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err))?;
return Ok(());
},
};
info!("creating file for bucket download: {:?}", filesystem_path.as_ref());
let mut output_file = File::create(filesystem_path).await?;
let result = bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
let result = self.bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
info!("downloading from bucket (named '{}'), path: {}", bucket.name, &object_path_str);
info!("downloading from bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
let status_code = match result {
Ok(status_code) => status_code,
Err(err) => {
return bail!("Error downloading from bucket (named '{}'): {:?}", bucket.name, err)
return bail!("Error downloading from bucket (named '{}'): {:?}", &self.bucket.name, err)
}
};
match status_code {
404 => {
error!("File not found in bucket (named '{}'), path: {}", bucket.name, &object_path_str);
error!("File not found in bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
bail!("File not found in bucket: {}", &object_path_str)
},
_ => {
@@ -335,70 +261,4 @@ impl LegacyBucketClient {
Ok(())
}
fn get_rooted_object_name(&self, object_name: &str) -> String {
match &self.optional_bucket_root {
None => object_name.to_string(),
Some(root) => format!("{}/{}", root, object_name),
}
}
}
/// Map a rooted object path (e.g. "/media/a/b/…/file.jpg") to a disk path
/// under `root`, segment by segment. This is the single source of truth for
/// the local-dir layout — the dev server's static /media mount and the fake
/// generation resolver must agree with it (disk = root + rooted path).
pub fn local_object_disk_path(root: &Path, rooted_object_path: &str) -> anyhow::Result<PathBuf> {
let mut path = root.to_path_buf();
for segment in rooted_object_path.split('/').filter(|s| !s.is_empty()) {
// Object names can come from user-controlled archives (e.g. PMX zip
// entries); never let them escape the root.
if segment == ".." || segment.contains('\\') {
bail!("refusing suspicious object path segment: {}", rooted_object_path);
}
path.push(segment);
}
Ok(path)
}
async fn write_local_object(root: &Path, rooted_object_path: &str, bytes: &[u8]) -> anyhow::Result<()> {
let disk_path = local_object_disk_path(root, rooted_object_path)?;
if let Some(parent) = disk_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&disk_path, bytes).await?;
info!("Wrote local bucket object: {}", disk_path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
mod local_object_disk_path_tests {
use super::*;
#[test]
fn maps_rooted_path_under_root() {
let path = local_object_disk_path(Path::new("/data"), "/media/a/b/file.jpg").unwrap();
assert_eq!(path, Path::new("/data").join("media").join("a").join("b").join("file.jpg"));
}
#[test]
fn ignores_leading_slash_and_empty_segments() {
let with_slash = local_object_disk_path(Path::new("/data"), "/media//x").unwrap();
let without_slash = local_object_disk_path(Path::new("/data"), "media/x").unwrap();
assert_eq!(with_slash, without_slash);
}
#[test]
fn rejects_parent_dir_traversal() {
assert!(local_object_disk_path(Path::new("/data"), "/media/../../etc/passwd").is_err());
}
#[test]
fn rejects_backslash_segments() {
assert!(local_object_disk_path(Path::new("/data"), "/media/a\\b").is_err());
}
}
}
@@ -1,421 +0,0 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::de_u64_int_or_float;
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_BILLING_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetBillingPaymentsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_BILLING_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of payments actually returned until a page comes back empty.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct BillingPaymentsHistoryPage {
/// Payments on this page, newest first.
pub payments: Vec<BillingPayment>,
/// Cursor of the row after this page (`None` on the last page). The
/// dashboard paginates by limit/offset; this is informational.
pub maybe_next_cursor: Option<String>,
/// Total payment rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One payment on the Kinovi billing dashboard (a credit-package purchase).
#[derive(Debug, Clone)]
pub struct BillingPayment {
pub id: String,
/// Payment amount in US dollars (the API returns dollars: `2159`, `99.99`).
pub amount_usd: f64,
pub status: BillingPaymentStatus,
pub payment_type: BillingPaymentType,
/// Kinovi credits granted by this payment (e.g. 525,000).
pub credits_earned: u64,
/// e.g. "Credits Package"
pub product_name: String,
pub transaction_id: String,
pub payment_id: String,
pub product_id: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-13T20:40:49.405Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
pub updated_at: String,
}
/// Payment status. Only `PAID` has been observed; unrecognised values are
/// preserved in `Unknown` so future statuses (refunds, chargebacks, pending)
/// don't break deserialization.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentStatus {
Paid,
Unknown(String),
}
impl BillingPaymentStatus {
fn from_str(s: &str) -> Self {
match s {
"PAID" => Self::Paid,
other => Self::Unknown(other.to_string()),
}
}
}
/// Payment type. Only `ONE_TIME` has been observed.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentType {
OneTime,
Unknown(String),
}
impl BillingPaymentType {
fn from_str(s: &str) -> Self {
match s {
"ONE_TIME" => Self::OneTime,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_billing_payments_history(
args: GetBillingPaymentsHistoryArgs<'_>,
) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/billing.getPayments", base_url);
info!("Fetching billing payments (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Billing payments response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let payments = json.payments
.into_iter()
.map(|p| {
let created_at_utc = DateTime::parse_from_rfc3339(&p.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
BillingPayment {
id: p.id,
amount_usd: p.amount,
status: BillingPaymentStatus::from_str(&p.payment_status),
payment_type: BillingPaymentType::from_str(&p.payment_type),
credits_earned: p.credits_earned,
product_name: p.product_name,
transaction_id: p.transaction_id,
payment_id: p.payment_id,
product_id: p.product_id,
created_at: p.created_at,
created_at_utc,
updated_at: p.updated_at,
}
})
.collect();
Ok(BillingPaymentsHistoryPage {
payments,
maybe_next_cursor: json.next_cursor,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the billing.getPayments endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
payments: Vec<RawPayment>,
#[serde(default)]
next_cursor: Option<String>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawPayment {
id: String,
amount: f64,
payment_status: String,
#[serde(default)]
transaction_id: String,
#[serde(default)]
payment_id: String,
#[serde(default)]
product_id: String,
#[serde(rename = "type")]
payment_type: String,
#[serde(deserialize_with = "de_u64_int_or_float")]
credits_earned: u64,
#[serde(default)]
product_name: String,
created_at: String,
#[serde(default)]
updated_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured billing.getPayments response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"payments":[
{"id":"31722","amount":2159,"paymentStatus":"PAID","transactionId":"","paymentId":"","createdAt":"2026-07-13T20:40:49.405Z","updatedAt":"2026-07-13T20:40:49.405Z","productId":"","type":"ONE_TIME","creditsEarned":525000,"productName":"Credits Package"},
{"id":"11491","amount":99.99,"paymentStatus":"PAID","transactionId":"202603141400154949180","paymentId":"202603141400154949180","createdAt":"2026-03-14T14:00:15.782Z","updatedAt":"2026-03-14T14:01:02.630Z","productId":"","type":"ONE_TIME","creditsEarned":22000,"productName":"Credits Package"}
],"nextCursor":"29564","total":997}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.payments.len(), 2);
assert_eq!(page.maybe_next_cursor.as_deref(), Some("29564"));
assert_eq!(page.total, 997);
let first = &page.payments[0];
assert_eq!(first.id, "31722");
assert_eq!(first.amount_usd, 2159.0);
assert_eq!(first.status, BillingPaymentStatus::Paid);
assert_eq!(first.payment_type, BillingPaymentType::OneTime);
assert_eq!(first.credits_earned, 525_000);
assert!(first.created_at_utc.is_some());
let second = &page.payments[1];
assert_eq!(second.amount_usd, 99.99);
assert_eq!(second.transaction_id, "202603141400154949180");
}
#[test]
fn last_page_has_no_next_cursor() {
let body = r#"[{"result":{"data":{"json":{"payments":[],"nextCursor":null,"total":997}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert!(page.payments.is_empty());
assert_eq!(page.maybe_next_cursor, None);
}
#[test]
fn unknown_status_and_type_are_preserved() {
let body = r#"[{"result":{"data":{"json":{"payments":[
{"id":"1","amount":5,"paymentStatus":"REFUNDED","type":"SUBSCRIPTION","creditsEarned":0,"createdAt":"2026-07-13T20:40:49.405Z"}
],"nextCursor":null,"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(page.payments[0].status, BillingPaymentStatus::Unknown("REFUNDED".to_string()));
assert_eq!(page.payments[0].payment_type, BillingPaymentType::Unknown("SUBSCRIPTION".to_string()));
}
}
mod live {
use super::*;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_payments_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("payments: {}, total: {}, next_cursor: {:?}",
page.payments.len(), page.total, page.maybe_next_cursor);
for payment in &page.payments {
println!(" {} | {} | ${:.2} | {} credits | {:?} | {:?}",
payment.id, payment.created_at, payment.amount_usd,
payment.credits_earned, payment.status, payment.payment_type);
}
assert!(!page.payments.is_empty());
Ok(())
}
/// Pages through the ENTIRE payments history with throttling and prints
/// lifetime totals. ~1,000 rows / 50 pages as of 2026-07.
/// cargo test -p seedance2pro_client live_paginate_entire_payments_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_payments_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 2_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut total_rows = 0u64;
let mut total_amount_usd = 0f64;
let mut total_credits = 0u64;
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.payments.is_empty() {
break;
}
for payment in &page.payments {
total_rows += 1;
total_amount_usd += payment.amount_usd;
total_credits += payment.credits_earned;
}
println!("page {} (offset {}): {} payments, running total ${:.2}",
pages, offset, page.payments.len(), total_amount_usd);
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.payments.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── payments history totals ──");
println!("pages: {}", pages);
println!("payments: {} (server reports total {})", total_rows, last_reported_total);
println!("lifetime spend: ${:.2}", total_amount_usd);
println!("lifetime credits: {}", total_credits);
assert!(total_rows > 0);
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,415 +0,0 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float};
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_CREDITS_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetCreditsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_CREDITS_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of entries actually returned until a page comes back empty.
/// NB: the ledger grows while you page (offsets shift under live traffic),
/// so dedupe by entry `id` when sweeping the full history.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct CreditsHistoryPage {
/// Ledger entries on this page, newest first.
pub entries: Vec<CreditHistoryEntry>,
/// Total ledger rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One row of the Kinovi credits ledger: a signed credit delta attributed to
/// a generation order (consumption) or a grant (purchase, registration bonus).
#[derive(Debug, Clone)]
pub struct CreditHistoryEntry {
pub id: u64,
pub entry_type: CreditHistoryEntryType,
/// Signed credit delta: negative for consumption, positive for grants.
pub credit_delta: i64,
/// The generation order this entry bills, when applicable (`ord_...`).
/// `None` for grants (purchases, registration bonuses).
pub maybe_order_id: Option<String>,
/// e.g. "Seedance 2.0 Video", "Credit Package - xl (25000 credits)"
pub product_name: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-14T00:31:12.820Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
}
/// Ledger entry type. Observed values are mapped; anything new (refunds,
/// failure credits, expirations, ...) lands in `Unknown` so deserialization
/// never breaks.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CreditHistoryEntryType {
/// Credits consumed by a successful generation (negative delta).
ConsumptionSuccess,
/// Credits granted by a package purchase (positive delta).
PurchasedCredits,
/// Sign-up bonus credits (positive delta).
NewUserRegistration,
Unknown(String),
}
impl CreditHistoryEntryType {
pub fn as_str(&self) -> &str {
match self {
Self::ConsumptionSuccess => "CONSUMPTION_SUCCESS",
Self::PurchasedCredits => "PURCHASED_CREDITS",
Self::NewUserRegistration => "NEW_USER_REGISTRATION",
Self::Unknown(other) => other.as_str(),
}
}
fn from_str(s: &str) -> Self {
match s {
"CONSUMPTION_SUCCESS" => Self::ConsumptionSuccess,
"PURCHASED_CREDITS" => Self::PurchasedCredits,
"NEW_USER_REGISTRATION" => Self::NewUserRegistration,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_credits_history(
args: GetCreditsHistoryArgs<'_>,
) -> Result<CreditsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/credits.getCreditHistory", base_url);
info!("Fetching credits history (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Credits history response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<CreditsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let entries = json.credits
.into_iter()
.map(|entry| {
let created_at_utc = DateTime::parse_from_rfc3339(&entry.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
CreditHistoryEntry {
id: entry.id,
entry_type: CreditHistoryEntryType::from_str(&entry.entry_type),
credit_delta: entry.credit,
maybe_order_id: entry.order_id,
product_name: entry.product_name,
created_at: entry.created_at,
created_at_utc,
}
})
.collect();
Ok(CreditsHistoryPage {
entries,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the credits.getCreditHistory endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
credits: Vec<RawCreditEntry>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawCreditEntry {
#[serde(deserialize_with = "de_u64_int_or_float")]
id: u64,
#[serde(rename = "type")]
entry_type: String,
#[serde(deserialize_with = "de_i64_int_or_float")]
credit: i64,
#[serde(default)]
order_id: Option<String>,
created_at: String,
#[serde(default)]
product_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured credits.getCreditHistory response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"credits":[
{"id":2750659,"type":"CONSUMPTION_SUCCESS","credit":-454,"orderId":"ord_mff2cjk2feczmzqweizcdal2","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"},
{"id":591799,"type":"PURCHASED_CREDITS","credit":25000,"orderId":null,"createdAt":"2026-02-19T08:16:58.524Z","productName":"Credit Package - xl (25000 credits)"},
{"id":563856,"type":"NEW_USER_REGISTRATION","credit":200,"orderId":null,"createdAt":"2026-02-18T01:02:52.747Z","productName":"unknown"}
],"total":304617}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.entries.len(), 3);
assert_eq!(page.total, 304_617);
let consumption = &page.entries[0];
assert_eq!(consumption.id, 2_750_659);
assert_eq!(consumption.entry_type, CreditHistoryEntryType::ConsumptionSuccess);
assert_eq!(consumption.credit_delta, -454);
assert_eq!(consumption.maybe_order_id.as_deref(), Some("ord_mff2cjk2feczmzqweizcdal2"));
assert!(consumption.created_at_utc.is_some());
let purchase = &page.entries[1];
assert_eq!(purchase.entry_type, CreditHistoryEntryType::PurchasedCredits);
assert_eq!(purchase.credit_delta, 25_000);
assert_eq!(purchase.maybe_order_id, None);
let registration = &page.entries[2];
assert_eq!(registration.entry_type, CreditHistoryEntryType::NewUserRegistration);
assert_eq!(registration.credit_delta, 200);
}
#[test]
fn unknown_entry_type_is_preserved() {
let body = r#"[{"result":{"data":{"json":{"credits":[
{"id":1,"type":"CONSUMPTION_REFUND","credit":454,"orderId":"ord_x","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"}
],"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(
page.entries[0].entry_type,
CreditHistoryEntryType::Unknown("CONSUMPTION_REFUND".to_string()),
);
assert_eq!(page.entries[0].credit_delta, 454);
}
#[test]
fn entry_type_round_trips_as_str() {
for raw in ["CONSUMPTION_SUCCESS", "PURCHASED_CREDITS", "NEW_USER_REGISTRATION", "SOMETHING_NEW"] {
assert_eq!(CreditHistoryEntryType::from_str(raw).as_str(), raw);
}
}
}
mod live {
use super::*;
use std::collections::HashMap;
use std::collections::HashSet;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_credits_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: DEFAULT_CREDITS_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("entries: {}, total: {}", page.entries.len(), page.total);
for entry in &page.entries {
println!(" {} | {} | {:?} | {} | {:?}",
entry.id, entry.created_at, entry.entry_type, entry.credit_delta,
entry.maybe_order_id);
}
assert!(!page.entries.is_empty());
Ok(())
}
/// Pages through the ENTIRE credits ledger with throttling and prints
/// per-type tallies (this is where any refund-like entry types will
/// surface). ~300k rows as of 2026-07 — expect a long run; the ledger
/// also grows while paging, so entries are deduped by id.
/// cargo test -p seedance2pro_client live_paginate_entire_credits_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_credits_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_LIMIT: u32 = 100;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 50_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut type_counts: HashMap<CreditHistoryEntryType, (u64, i64)> = HashMap::new();
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.entries.is_empty() {
break;
}
for entry in &page.entries {
if !seen_ids.insert(entry.id) {
continue; // offset shifted under live traffic; already counted
}
let tally = type_counts.entry(entry.entry_type.clone()).or_insert((0, 0));
tally.0 += 1;
tally.1 += entry.credit_delta;
}
if pages % 25 == 0 {
println!("page {} (offset {}): {} unique entries so far (server total {})",
pages, offset, seen_ids.len(), page.total);
}
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.entries.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── credits ledger totals ──");
println!("pages: {}", pages);
println!("unique entries: {} (server reports total {})", seen_ids.len(), last_reported_total);
let mut types: Vec<_> = type_counts.iter().collect();
types.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (entry_type, (count, delta_sum)) in types {
println!(" {:<28} n={:<8} credit_sum={}", entry_type.as_str(), count, delta_sum);
}
assert!(!seen_ids.is_empty());
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,2 +0,0 @@
pub mod get_billing_payments_history;
pub mod get_credits_history;
@@ -1,620 +0,0 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "seedream-5-pro-image-generation";
const MODEL: &str = "seedream-5.0-pro";
/// Kinovi allows at most this many reference images per request.
pub const MAX_REFERENCE_IMAGES: usize = 14;
/// Credits per generated image at 1k resolution.
const CREDITS_PER_IMAGE_1K: u64 = 14;
/// Credits per generated image at 2k resolution.
const CREDITS_PER_IMAGE_2K: u64 = 24;
// ── Args ──
pub struct GenerateSeedream5p0ProArgs<'a> {
pub request: GenerateSeedream5p0ProRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Seedream 5.0 Pro: text-to-image (optionally with reference images).
#[derive(Clone, Debug)]
pub struct GenerateSeedream5p0ProRequest {
/// Reference images are cited in the prompt as @Image1, @Image2, etc.
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
/// Number of images to generate in a single request.
pub batch_count: KinoviSeedream5p0ProBatchCount,
/// Optional reference image URLs, up to [`MAX_REFERENCE_IMAGES`].
/// Referenced in prompts as @Image1, @Image2, etc.
pub reference_image_urls: Option<Vec<String>>,
}
// ── Enums ──
/// Aspect ratios supported by Seedream 5.0 Pro.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProAspectRatio {
/// Let the model pick (useful with reference images).
Auto,
Square1x1,
Standard4x3,
Portrait3x4,
Landscape16x9,
Portrait9x16,
Wide3x2,
Tall2x3,
UltraWide21x9,
}
/// Output resolution. Determines the per-image credit price.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProResolution {
/// 1k (14 credits per image)
OneK,
/// 2k (24 credits per image)
TwoK,
}
/// Number of images to generate in a single request.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedream 5.0 Pro credit pricing (per image; batch count multiplies):
//
// | Resolution | Credits/image |
// |------------|---------------|
// | 1k | 14 |
// | 2k | 24 |
//
// Reference images do not affect cost.
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedream5p0ProRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
let credits_per_image = match self.resolution {
KinoviSeedream5p0ProResolution::OneK => CREDITS_PER_IMAGE_1K,
KinoviSeedream5p0ProResolution::TwoK => CREDITS_PER_IMAGE_2K,
};
let total_credits = credits_per_image * u64::from(self.batch_count.as_u8());
KinoviGenerationCost::from_kinovi_credits(total_credits)
}
}
impl KinoviSeedream5p0ProBatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
Self::Five => 5,
Self::Six => 6,
Self::Seven => 7,
Self::Eight => 8,
}
}
}
impl KinoviSeedream5p0ProAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Square1x1 => "1:1",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
Self::UltraWide21x9 => "21:9",
}
}
}
impl KinoviSeedream5p0ProResolution {
fn as_api_str(&self) -> &'static str {
match self {
Self::OneK => "1k",
Self::TwoK => "2k",
}
}
}
// ── Response ──
pub struct GenerateSeedream5p0ProResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedream_5p0_pro(
args: GenerateSeedream5p0ProArgs<'_>,
) -> Result<GenerateSeedream5p0ProResponse, Seedance2ProError> {
let api_params = build_api_params(&args.request)?;
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedream5p0ProResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `seedream-5-pro-image-generation`. Field order
/// matches the captured browser traffic.
#[derive(Serialize, Debug)]
struct Seedream5p0ProApiParams {
prompt: String,
#[serde(rename = "aspectRatio")]
aspect_ratio: &'static str,
resolution: &'static str,
model: &'static str,
/// Omitted when generating a single image.
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
batch_count: Option<u8>,
/// Omitted when no reference images are attached.
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
uploaded_urls: Option<Vec<String>>,
}
fn build_api_params(
request: &GenerateSeedream5p0ProRequest,
) -> Result<Seedream5p0ProApiParams, Seedance2ProClientError> {
let uploaded_urls = match &request.reference_image_urls {
Some(urls) if urls.len() > MAX_REFERENCE_IMAGES => {
return Err(Seedance2ProClientError::InvalidRequestField {
field: "reference_image_urls",
raw_value: format!("{} urls", urls.len()),
reason: format!("At most {} reference images are allowed", MAX_REFERENCE_IMAGES),
});
}
Some(urls) if !urls.is_empty() => Some(urls.clone()),
_ => None,
};
let batch_count_value = request.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
Ok(Seedream5p0ProApiParams {
prompt: request.prompt.clone(),
aspect_ratio: request.aspect_ratio.as_api_str(),
resolution: request.resolution.as_api_str(),
model: MODEL,
batch_count,
uploaded_urls,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
prompt: "An anime girl riding a dinosaur".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 1_seedream_5.txt (auto aspect ratio, 2k, single image,
/// no references).
#[test]
fn auto_2k_single_image() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl riding a dinosaur","aspectRatio":"auto","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 2_seedream_5_21x9.txt (21:9, 2k, single image).
#[test]
fn ultra_wide_21x9_2k() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "a pirate sailing a pirate ship".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"a pirate sailing a pirate ship","aspectRatio":"21:9","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 3_seedream_5_9x16_image_refs.txt (9:16, 1k, single
/// image, two reference images).
#[test]
fn portrait_9x16_1k_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The man in @Image1 is staring out the door in @Image2 ","aspectRatio":"9:16","resolution":"1k","model":"seedream-5.0-pro","uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg","https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png"]}"#,
);
}
/// Mirrors capture 4_seedream_5_misc.txt (2:3, 2k, batch of 4, two
/// reference images).
#[test]
fn tall_2x3_2k_batch_four_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Tall2x3,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ","aspectRatio":"2:3","resolution":"2k","model":"seedream-5.0-pro","batchCount":4,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png","https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg"]}"#,
);
}
/// Mirrors capture 5_seedream_5_misc2.txt (1:1, 1k, batch of 8, two
/// reference images).
#[test]
fn square_1x1_1k_batch_eight_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Square1x1,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ","aspectRatio":"1:1","resolution":"1k","model":"seedream-5.0-pro","batchCount":8,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg","https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png"]}"#,
);
}
#[test]
fn every_aspect_ratio_wire_value() {
let cases = [
(KinoviSeedream5p0ProAspectRatio::Auto, "auto"),
(KinoviSeedream5p0ProAspectRatio::Square1x1, "1:1"),
(KinoviSeedream5p0ProAspectRatio::Standard4x3, "4:3"),
(KinoviSeedream5p0ProAspectRatio::Portrait3x4, "3:4"),
(KinoviSeedream5p0ProAspectRatio::Landscape16x9, "16:9"),
(KinoviSeedream5p0ProAspectRatio::Portrait9x16, "9:16"),
(KinoviSeedream5p0ProAspectRatio::Wide3x2, "3:2"),
(KinoviSeedream5p0ProAspectRatio::Tall2x3, "2:3"),
(KinoviSeedream5p0ProAspectRatio::UltraWide21x9, "21:9"),
];
for (variant, expected) in cases {
assert_eq!(variant.as_api_str(), expected, "wire value for {variant:?}");
}
}
#[test]
fn every_resolution_wire_value() {
assert_eq!(KinoviSeedream5p0ProResolution::OneK.as_api_str(), "1k");
assert_eq!(KinoviSeedream5p0ProResolution::TwoK.as_api_str(), "2k");
}
#[test]
fn every_batch_count_value() {
let cases = [
(KinoviSeedream5p0ProBatchCount::One, 1),
(KinoviSeedream5p0ProBatchCount::Two, 2),
(KinoviSeedream5p0ProBatchCount::Three, 3),
(KinoviSeedream5p0ProBatchCount::Four, 4),
(KinoviSeedream5p0ProBatchCount::Five, 5),
(KinoviSeedream5p0ProBatchCount::Six, 6),
(KinoviSeedream5p0ProBatchCount::Seven, 7),
(KinoviSeedream5p0ProBatchCount::Eight, 8),
];
for (variant, expected) in cases {
assert_eq!(variant.as_u8(), expected, "value for {variant:?}");
}
}
#[test]
fn batch_count_one_omits_field() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(params.batch_count, None);
}
#[test]
fn empty_reference_list_omits_uploaded_urls() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![]),
..base_request()
}).unwrap();
let json = serde_json::to_string(&params).unwrap();
assert!(!json.contains("uploadedUrls"), "expected no uploadedUrls in {json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "seedream-5-pro-image-generation");
}
}
mod reference_image_limit_tests {
use super::*;
#[test]
fn fourteen_references_is_allowed() {
let params = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES)).unwrap();
assert_eq!(params.uploaded_urls.map(|urls| urls.len()), Some(MAX_REFERENCE_IMAGES));
}
#[test]
fn fifteen_references_is_rejected() {
let result = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES + 1));
match result {
Err(Seedance2ProClientError::InvalidRequestField { field, .. }) => {
assert_eq!(field, "reference_image_urls");
}
other => panic!("expected InvalidRequestField, got {other:?}"),
}
}
fn request_with_n_references(count: usize) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
reference_image_urls: Some(
(0..count).map(|i| format!("https://example.com/ref{i}.jpg")).collect(),
),
..base_request()
}
}
}
// ── Pricing ──
//
// 1k = 14 credits/image, 2k = 24 credits/image; batch count multiplies.
// Verified against captured traffic: 2k x 4 images = 96 credits
// (4_seedream_5_misc.txt), 1k x 8 images = 112 credits
// (5_seedream_5_misc2.txt).
mod pricing_tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 14);
}
#[test]
fn one_2k_image_is_twentyfour_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 24);
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
assert_eq!(costs.kinovi_credits, 96);
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.kinovi_credits, 112);
}
#[test]
fn usd_cents_one_1k_image() {
// 14 credits; 1400/243 = 5.7613… → up 6¢, down 5¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 6);
assert_eq!(costs.usd_cents_rounded_down, 5);
assert!((costs.usd_cents_fractional - (1400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_one_2k_image() {
// 24 credits; 2400/243 = 9.8765… → up 10¢, down 9¢.
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 10);
assert_eq!(costs.usd_cents_rounded_down, 9);
assert!((costs.usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_eight_1k_images() {
// 112 credits; 11200/243 = 46.0905… → up 47¢, down 46¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 47);
assert_eq!(costs.usd_cents_rounded_down, 46);
assert!((costs.usd_cents_fractional - (11200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn reference_images_do_not_affect_cost() {
let without_refs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
let with_refs = GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
]),
..request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four)
}.calculate_costs();
assert_eq!(without_refs, with_refs);
}
#[test]
fn aspect_ratio_does_not_affect_cost() {
let auto = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
let ultra_wide = GenerateSeedream5p0ProRequest {
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
..request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One)
}.calculate_costs();
assert_eq!(auto, ultra_wide);
}
fn request(
resolution: KinoviSeedream5p0ProResolution,
batch_count: KinoviSeedream5p0ProBatchCount,
) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
resolution,
batch_count,
..base_request()
}
}
}
// ── Live usage tests ──
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 24 credits.
async fn test_text_to_image_2k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A corgi wearing a wizard hat, studio lighting".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro 2k — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 14 credits.
async fn test_text_to_image_1k_with_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
},
}).await?;
println!("seedream 5.0 pro 1k refs — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 56 credits.
async fn test_batch_four_1k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A lighthouse on a cliff at golden hour".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Landscape16x9,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro batch-4 — task_id={}, order_id={}", result.task_id, result.order_id);
assert_eq!(result.task_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(result.order_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,4 +1,3 @@
pub mod generate_midjourney_v7;
pub mod generate_midjourney_v7_niji;
pub mod generate_midjourney_v8;
pub mod generate_seedream_5p0_pro;
@@ -2,7 +2,6 @@
pub (crate) mod test_utils;
// User API
pub mod billing;
pub mod cost;
pub mod creds;
pub mod error;
@@ -19,9 +19,7 @@ pub fn setup_test_logging(level: LevelFilter) {
println!("Log level: {:?}", env::var(RUST_LOG));
// try_init: several #[ignore] live tests may run in one process, and only
// the first can install the logger.
let _ = Builder::new()
Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
@@ -33,7 +31,7 @@ pub fn setup_test_logging(level: LevelFilter) {
})
.filter(None, level)
.filter_level(level)
.try_init();
.init();
trace!("Test trace log");
debug!("Test debug log");
@@ -28,20 +28,6 @@ fn coerce_to_u32(number: &Number) -> u32 {
coerce_to_u64(number).min(u32::MAX as u64) as u32
}
/// Coerce a parsed JSON number to `i64`. Exact integers pass through; floats
/// are rounded; non-finite values become `0`.
fn coerce_to_i64(number: &Number) -> i64 {
if let Some(signed) = number.as_i64() {
return signed;
}
if let Some(float) = number.as_f64() {
if float.is_finite() {
return float.round() as i64;
}
}
0
}
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
/// arrive as an integer or a float.
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
@@ -52,16 +38,6 @@ where
Ok(coerce_to_u64(&number))
}
/// `#[serde(deserialize_with = "...")]` for a required `i64` field that may
/// arrive as an integer or a float (e.g. signed credit deltas).
pub fn de_i64_int_or_float<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_i64(&number))
}
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
/// `null` / missing as `None`).
pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
@@ -114,12 +90,6 @@ mod tests {
value: Option<u32>,
}
#[derive(Deserialize)]
struct I64Holder {
#[serde(deserialize_with = "de_i64_int_or_float")]
value: i64,
}
fn parse_u32(json: &str) -> u32 {
serde_json::from_str::<U32Holder>(json).unwrap().value
}
@@ -128,10 +98,6 @@ mod tests {
serde_json::from_str::<U64Holder>(json).unwrap().value
}
fn parse_i64(json: &str) -> i64 {
serde_json::from_str::<I64Holder>(json).unwrap().value
}
#[test]
fn u32_accepts_integer() {
assert_eq!(parse_u32(r#"{"value": 568}"#), 568);
@@ -160,13 +126,6 @@ mod tests {
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
}
#[test]
fn i64_accepts_negative_integers_and_floats() {
assert_eq!(parse_i64(r#"{"value": -454}"#), -454);
assert_eq!(parse_i64(r#"{"value": -454.6}"#), -455);
assert_eq!(parse_i64(r#"{"value": 25000}"#), 25000);
}
#[test]
fn optional_handles_null_missing_and_values() {
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
@@ -7,10 +7,6 @@ use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &[
"account_info",
"audit_credits",
"audit_orders",
"audit_payments",
"failed_job_histogram",
"find_job",
"generate_video",
@@ -19,21 +15,6 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Print the account's current credit balances as one CSV line
AccountInfo(subcommands::account_info::AccountInfoArgs),
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV
/// back to a start date, for refund-entry auditing
AuditCredits(subcommands::audit_credits::AuditCreditsArgs),
/// Dump Kinovi's per-order billing records (totalCredits) to CSV for a
/// date window, to audit whether failed orders are charged
AuditOrders(subcommands::audit_orders::AuditOrdersArgs),
/// Dump the account's billing payments history (credit-package
/// purchases) to CSV, for reconciling refills against invoices
AuditPayments(subcommands::audit_payments::AuditPaymentsArgs),
/// Find a job by its order ID across all pages
FindJob(subcommands::find_job::FindJobArgs),
@@ -51,10 +32,6 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
Seedance2proCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
Seedance2proCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
Seedance2proCommand::AuditPayments(args) => subcommands::audit_payments::run(&state, args).await,
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
@@ -1,46 +0,0 @@
use anyhow::anyhow;
use clap::Args;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::get_user_auth_details::get_user_auth_details::{
get_user_auth_details, GetUserAuthDetailsArgs,
};
use super::super::state::Seedance2ProState;
/// Print the account's current credit balances (auth.user endpoint) as a
/// single CSV line: account,email,credits,available_credits.
#[derive(Args)]
pub struct AccountInfoArgs {
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label for the output line. Defaults to the cookies env var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AccountInfoArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let details = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
host_override: None,
}).await
.map_err(|err| anyhow!("Error fetching auth details: {:?}", err))?;
println!("{},{},{},{}",
account_label, details.email, details.credits, details.available_credits);
Ok(())
}
@@ -1,167 +0,0 @@
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro_client::billing::get_credits_history::{
get_credits_history, CreditHistoryEntry, GetCreditsHistoryArgs,
};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use super::super::state::Seedance2ProState;
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV,
/// newest-first, back to a start date. The ledger is offset-paginated and
/// grows while paging (new rows push older rows to higher offsets), which
/// causes re-reads rather than gaps - consumers should dedupe by entry id.
#[derive(Args)]
pub struct AuditCreditsArgs {
/// Output CSV path. Appends if the file exists.
#[arg(long)]
pub out: String,
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
#[arg(long)]
pub start_date: Option<String>,
/// Page size to request (the server may cap it; paging advances by rows
/// actually returned, so a cap cannot skip rows).
#[arg(long, default_value_t = 100)]
pub limit: u32,
/// Maximum pages to fetch this run.
#[arg(long, default_value_t = 10_000)]
pub max_pages: usize,
/// Resume from a row offset (e.g. after an interrupted run).
#[arg(long, default_value_t = 0)]
pub offset: u64,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditCreditsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,entry_id,created_at,entry_type,credit_delta,order_id,product_name")?;
}
let mut offset = args.offset;
let mut rows = 0u64;
let mut type_tallies: HashMap<String, (u64, i64)> = HashMap::new();
let mut last_reported_total = 0u64;
for page_number in 1..=args.max_pages {
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: args.limit,
offset,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error fetching credits page {} (resume with --offset {}): {:?}",
page_number, offset, err))?;
last_reported_total = page.total;
if page.entries.is_empty() {
info!("Page {} returned no entries; reached the end of the ledger.", page_number);
break;
}
let newest_day = page.entries.first().map(|e| day_of(e)).unwrap_or_default();
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
for entry in &page.entries {
write_entry_row(&mut out, &account_label, entry)?;
rows += 1;
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
tally.0 += 1;
tally.1 += entry.credit_delta;
}
out.flush()?;
if page_number % 25 == 0 || page_number == 1 {
info!(
"Page {} (offset {}): {} entries ({} .. {}), written {} (server total {})",
page_number, offset, page.entries.len(), newest_day, oldest_day, rows, page.total,
);
}
// Newest-first: once a whole page predates the window, everything
// after it does too.
if args.start_date.as_deref().is_some_and(|start| oldest_day.as_str() < start) {
info!("Reached entries older than {}; done.", args.start_date.as_deref().unwrap());
break;
}
offset += page.entries.len() as u64;
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
println!("── audit_credits summary [{}] ──", account_label);
println!("entries written: {} (server reports ledger total {}; duplicates possible", rows, last_reported_total);
println!(" under live growth - dedupe by entry_id downstream)");
let mut tallies: Vec<_> = type_tallies.iter().collect();
tallies.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (entry_type, (count, delta_sum)) in tallies {
println!(" {:<28} n={:<9} credit_sum={}", entry_type, count, delta_sum);
}
Ok(())
}
/// UTC day prefix ("YYYY-MM-DD"); ISO strings compare lexicographically.
fn day_of(entry: &CreditHistoryEntry) -> String {
entry.created_at.get(..10).unwrap_or("").to_string()
}
fn write_entry_row(out: &mut impl Write, account: &str, entry: &CreditHistoryEntry) -> anyhow::Result<()> {
writeln!(
out,
"{},{},{},{},{},{},{}",
account,
entry.id,
entry.created_at,
entry.entry_type.as_str(),
entry.credit_delta,
entry.maybe_order_id.as_deref().unwrap_or(""),
csv_quote(&entry.product_name),
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -1,243 +0,0 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::{info, warn};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::poll_orders::poll_orders::{
poll_orders, OrderStatus, PollOrdersArgs, TaskStatus,
};
use super::super::state::Seedance2ProState;
/// Dump Kinovi's own per-order billing records (`totalCredits`) to CSV for a
/// date window, by paging the getOrders API newest-to-oldest. Used to audit
/// whether failed orders carry charges.
#[derive(Args)]
pub struct AuditOrdersArgs {
/// Output CSV path. Appends if the file exists (resume-friendly).
#[arg(long)]
pub out: String,
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
/// Without it, paging stops at --max_pages.
#[arg(long)]
pub start_date: Option<String>,
/// Skip (do not write) orders on days after this UTC date (YYYY-MM-DD,
/// inclusive). Pages still have to be fetched to reach older orders.
#[arg(long)]
pub end_date: Option<String>,
/// Maximum pages to fetch this run (30 orders per page).
#[arg(long, default_value_t = 200)]
pub max_pages: usize,
/// Resume from a cursor logged by a previous run.
#[arg(long)]
pub cursor: Option<u64>,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name (or "SEEDANCE2PRO_COOKIES").
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditOrdersArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,order_id,created_at,task_status,media_type,total_credits,result_count,fail_reason")?;
}
let mut cursor = args.cursor;
let mut stats = AuditStats::default();
for page in 1..=args.max_pages {
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error polling page {} (resume with --cursor {:?}): {:?}", page, cursor, err))?;
if result.orders.is_empty() {
info!("Page {} returned no orders; stopping.", page);
break;
}
let newest = order_day(result.orders.first().unwrap());
let oldest = order_day(result.orders.last().unwrap());
for order in &result.orders {
let day = order_day(order);
if args.end_date.as_deref().is_some_and(|end| day > end) {
stats.skipped_after_window += 1;
continue;
}
if args.start_date.as_deref().is_some_and(|start| day < start) {
stats.skipped_before_window += 1;
continue;
}
write_order_row(&mut out, &account_label, order)?;
stats.record(order);
}
out.flush()?;
info!(
"Page {}: {} orders ({} .. {}), written so far: {}, next cursor: {:?}",
page, result.orders.len(), newest, oldest, stats.written, result.next_cursor,
);
// The API pages newest-to-oldest: once the whole page predates the
// window, everything after it will too.
if args.start_date.as_deref().is_some_and(|start| oldest < start) {
info!("Reached orders older than {}; done.", args.start_date.as_deref().unwrap());
cursor = result.next_cursor;
break;
}
cursor = result.next_cursor;
if cursor.is_none() {
info!("No further pages; reached the end of the account's history.");
break;
}
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
stats.print_summary(&account_label, cursor);
Ok(())
}
#[derive(Default)]
struct AuditStats {
written: u64,
skipped_after_window: u64,
skipped_before_window: u64,
completed: u64,
completed_credits: u64,
completed_missing_credits: u64,
failed: u64,
failed_credits: u64,
failed_with_nonzero_credits: u64,
in_flight: u64,
in_flight_credits: u64,
}
impl AuditStats {
fn record(&mut self, order: &OrderStatus) {
self.written += 1;
let credits = u64::from(order.total_credits.unwrap_or(0));
match order.task_status {
TaskStatus::Completed => {
self.completed += 1;
self.completed_credits += credits;
if order.total_credits.is_none() {
self.completed_missing_credits += 1;
}
}
TaskStatus::Failed => {
self.failed += 1;
self.failed_credits += credits;
if credits > 0 {
self.failed_with_nonzero_credits += 1;
}
}
_ => {
self.in_flight += 1;
self.in_flight_credits += credits;
}
}
}
fn print_summary(&self, account: &str, resume_cursor: Option<u64>) {
// Final results are intentionally println: they ARE the tool's output.
println!("── audit_orders summary [{}] ──", account);
println!("orders written: {}", self.written);
println!(" completed: {} ({} credits; {} missing totalCredits)",
self.completed, self.completed_credits, self.completed_missing_credits);
println!(" failed: {} ({} credits; {} orders with credits > 0)",
self.failed, self.failed_credits, self.failed_with_nonzero_credits);
println!(" pending/processing: {} ({} credits)", self.in_flight, self.in_flight_credits);
println!("skipped (newer than window): {}", self.skipped_after_window);
println!("skipped (older than window): {}", self.skipped_before_window);
match resume_cursor {
Some(c) => println!("resume cursor: {}", c),
None => println!("resume cursor: (none - history exhausted or window done)"),
}
if self.failed_with_nonzero_credits > 0 {
warn!(
"{} FAILED orders carry non-zero totalCredits ({} credits total) — \
Kinovi's own records say failures were charged.",
self.failed_with_nonzero_credits, self.failed_credits,
);
}
}
}
/// UTC day prefix ("YYYY-MM-DD") of the order's ISO 8601 created_at.
/// ISO strings compare lexicographically, so no date parsing is needed.
fn order_day(order: &OrderStatus) -> &str {
order.created_at.get(..10).unwrap_or("")
}
fn write_order_row(out: &mut impl Write, account: &str, order: &OrderStatus) -> anyhow::Result<()> {
let media_type = order.media_type.as_ref()
.map(|m| format!("{:?}", m))
.unwrap_or_default();
let total_credits = order.total_credits
.map(|c| c.to_string())
.unwrap_or_default();
let fail_reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("");
writeln!(
out,
"{},{},{},{:?},{},{},{},{}",
account,
order.order_id,
order.created_at,
order.task_status,
media_type,
total_credits,
order.results.len(),
csv_quote(fail_reason),
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -1,131 +0,0 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro_client::billing::get_billing_payments_history::{
get_billing_payments_history, BillingPayment, GetBillingPaymentsHistoryArgs,
DEFAULT_BILLING_PAGE_LIMIT,
};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use super::super::state::Seedance2ProState;
/// Dump the account's full billing payments history (credit-package
/// purchases) to CSV, for reconciling refill charges against invoices.
#[derive(Args)]
pub struct AuditPaymentsArgs {
/// Output CSV path. Appends if the file exists.
#[arg(long)]
pub out: String,
/// Maximum pages to fetch (20 payments per page).
#[arg(long, default_value_t = 500)]
pub max_pages: usize,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,payment_id,created_at,amount_usd,credits_earned,status,payment_type,product_name,transaction_id")?;
}
let mut offset: u64 = 0;
let mut rows = 0u64;
let mut total_amount = 0f64;
let mut total_credits = 0u64;
let mut last_reported_total = 0u64;
for page_number in 1..=args.max_pages {
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error fetching payments page {} (offset {}): {:?}", page_number, offset, err))?;
last_reported_total = page.total;
if page.payments.is_empty() {
break;
}
for payment in &page.payments {
write_payment_row(&mut out, &account_label, payment)?;
rows += 1;
total_amount += payment.amount_usd;
total_credits += payment.credits_earned;
}
out.flush()?;
info!(
"Page {} (offset {}): {} payments; running ${:.2} / {} credits (server total {})",
page_number, offset, page.payments.len(), total_amount, total_credits, page.total,
);
offset += page.payments.len() as u64;
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
println!("── audit_payments summary [{}] ──", account_label);
println!("payments written: {} (server reports total {})", rows, last_reported_total);
println!("lifetime charged: ${:.2}", total_amount);
println!("lifetime credits: {}", total_credits);
Ok(())
}
fn write_payment_row(out: &mut impl Write, account: &str, payment: &BillingPayment) -> anyhow::Result<()> {
writeln!(
out,
"{},{},{},{},{},{:?},{:?},{},{}",
account,
payment.id,
payment.created_at,
payment.amount_usd,
payment.credits_earned,
payment.status,
payment.payment_type,
csv_quote(&payment.product_name),
payment.transaction_id,
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -1,7 +1,3 @@
pub mod account_info;
pub mod audit_credits;
pub mod audit_orders;
pub mod audit_payments;
pub mod failed_job_histogram;
pub mod find_job;
pub mod generate_video;
@@ -54,7 +54,6 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4),
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
@@ -82,8 +82,7 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
let is_image_model = match args.maybe_model_type {
Some(CommonModelType::Midjourney7) |
Some(CommonModelType::Midjourney7Niji) |
Some(CommonModelType::Midjourney8) |
Some(CommonModelType::Seedream5p0Pro) => true,
Some(CommonModelType::Midjourney8) => true,
_ => false,
};
@@ -1,64 +0,0 @@
use sqlx::{MySqlPool, Row};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
/// A pending job created by the DEV_FAKE_GENERATION short-circuit
/// (external_third_party_id starts with `fake_`), joined with its prompt text
/// so the dev resolver can honor the synthetic-failure prompt triggers.
pub struct PendingFakeGenerationJob {
pub job_token: InferenceJobToken,
/// e.g. "image_generation", "video_generation".
pub inference_category: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_positive_prompt: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
}
/// List pending fake-generation jobs older than `min_age_seconds`, oldest
/// first. Development-only: production never writes `fake_` external ids.
///
/// NB: runtime query (not the compile-time macro) because this dev-only SELECT
/// isn't in the sqlx offline cache.
pub async fn list_pending_fake_generation_jobs(
mysql_pool: &MySqlPool,
min_age_seconds: u32,
limit: u32,
) -> Result<Vec<PendingFakeGenerationJob>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT
j.token AS job_token,
j.inference_category,
j.maybe_prompt_token,
j.maybe_creator_user_token,
p.maybe_positive_prompt
FROM generic_inference_jobs AS j
LEFT JOIN prompts AS p
ON p.token = j.maybe_prompt_token
WHERE j.status = 'pending'
AND j.maybe_external_third_party_id LIKE 'fake\_%'
AND j.created_at <= NOW() - INTERVAL ? SECOND
ORDER BY j.created_at ASC
LIMIT ?
"#)
.bind(min_age_seconds)
.bind(limit)
.fetch_all(mysql_pool)
.await?;
let jobs = rows.into_iter()
.map(|row| PendingFakeGenerationJob {
job_token: InferenceJobToken::new(row.get::<String, _>("job_token")),
inference_category: row.get::<String, _>("inference_category"),
maybe_prompt_token: row.get::<Option<String>, _>("maybe_prompt_token")
.map(PromptToken::new),
maybe_positive_prompt: row.get::<Option<String>, _>("maybe_positive_prompt"),
maybe_creator_user_token: row.get::<Option<String>, _>("maybe_creator_user_token")
.map(UserToken::new),
})
.collect();
Ok(jobs)
}
@@ -8,7 +8,6 @@ pub mod job_status;
pub mod kill_generic_inference_jobs;
pub mod list_user_jobs_for_moderation;
pub mod kill_jobs_in_development;
pub mod list_pending_fake_generation_jobs;
pub mod list_session_jobs;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_successfully_done_by_token;
@@ -127,8 +127,6 @@ pub enum InferenceModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_v6")]
@@ -336,7 +334,6 @@ impl InferenceModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -465,7 +462,6 @@ impl InferenceModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -598,7 +594,6 @@ impl InferenceModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -714,7 +709,6 @@ impl InferenceModelType {
CommonModelType::Seedream4 => Self::Seedream4,
CommonModelType::Seedream4p5 => Self::Seedream4p5,
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
CommonModelType::Midjourney => Self::Midjourney,
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
@@ -853,7 +847,6 @@ mod tests {
assert_serialization(InferenceModelType::Seedream4, "seedream_4");
assert_serialization(InferenceModelType::Seedream4p5, "seedream_4p5");
assert_serialization(InferenceModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(InferenceModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(InferenceModelType::Midjourney, "midjourney");
assert_serialization(InferenceModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(InferenceModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -979,7 +972,6 @@ mod tests {
assert_eq!(InferenceModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(InferenceModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(InferenceModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(InferenceModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(InferenceModelType::Midjourney.to_str(), "midjourney");
assert_eq!(InferenceModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(InferenceModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -1107,7 +1099,6 @@ mod tests {
assert_eq!(InferenceModelType::from_str("seedream_4").unwrap(), InferenceModelType::Seedream4);
assert_eq!(InferenceModelType::from_str("seedream_4p5").unwrap(), InferenceModelType::Seedream4p5);
assert_eq!(InferenceModelType::from_str("seedream_5_lite").unwrap(), InferenceModelType::Seedream5Lite);
assert_eq!(InferenceModelType::from_str("seedream_5p0_pro").unwrap(), InferenceModelType::Seedream5p0Pro);
assert_eq!(InferenceModelType::from_str("midjourney").unwrap(), InferenceModelType::Midjourney);
assert_eq!(InferenceModelType::from_str("midjourney_v6").unwrap(), InferenceModelType::MidjourneyV6);
assert_eq!(InferenceModelType::from_str("midjourney_v6p1").unwrap(), InferenceModelType::MidjourneyV6p1);
@@ -47,8 +47,6 @@ pub enum CommonImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -77,7 +75,6 @@ impl CommonImageModel {
Self::Seedream4 => CommonModelType::Seedream4,
Self::Seedream4p5 => CommonModelType::Seedream4p5,
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
}
@@ -114,7 +111,6 @@ mod tests {
assert_serialization(CommonImageModel::Seedream4, "seedream_4");
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
}
@@ -140,7 +136,6 @@ mod tests {
(CommonImageModel::Seedream4, CommonModelType::Seedream4),
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
];
@@ -169,7 +164,7 @@ mod tests {
#[test]
fn variant_count() {
use strum::IntoEnumIterator;
assert_eq!(CommonImageModel::iter().len(), 21);
assert_eq!(CommonImageModel::iter().len(), 20);
}
#[test]
@@ -82,9 +82,6 @@ pub enum CommonModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
/// Seedream 5.0 Pro as routed via kinovi
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
/// Midjourney without distinguishing a model type or version
#[serde(rename = "midjourney")]
@@ -290,7 +287,6 @@ impl CommonModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -402,7 +398,6 @@ impl CommonModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -518,7 +513,6 @@ impl CommonModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -631,7 +625,6 @@ impl CommonModelType {
Self::Seedream4 => CommonModelClass::Image,
Self::Seedream4p5 => CommonModelClass::Image,
Self::Seedream5Lite => CommonModelClass::Image,
Self::Seedream5p0Pro => CommonModelClass::Image,
Self::Midjourney => CommonModelClass::Image,
Self::MidjourneyV6 => CommonModelClass::Image,
Self::MidjourneyV6p1 => CommonModelClass::Image,
@@ -754,7 +747,6 @@ mod tests {
assert_serialization(CommonModelType::Seedream4, "seedream_4");
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonModelType::Midjourney, "midjourney");
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -860,7 +852,6 @@ mod tests {
assert_eq!(CommonModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -970,7 +961,6 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedream_4").unwrap(), CommonModelType::Seedream4);
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
@@ -1052,7 +1042,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 97);
assert_eq!(variants.len(), 96);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -1079,7 +1069,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
+4 -4
View File
@@ -75,23 +75,23 @@ curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"newuser4","password":"newuser4"}'
# 2. Generate video (Seedance 2.0 Fast):
# 2. Generate video (GmiCloud Seedance 2.0 Fast Global):
# Requires a UUID-format idempotency_token.
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_fast",
"model": "seedance_2p0_fast_g",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}'
# 3. Generate video (Seedance 2.0, 720p):
# 3. Generate video (GmiCloud Seedance 2.0 Global, 720p):
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"model": "seedance_2p0_g",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"resolution": "seven_twenty_p",
@@ -167,7 +167,6 @@ tempfile.workspace = true
# https://github.com/durch/rust-s3/pull/254
rust-s3.workspace = true
actix-files.workspace = true
actix-http.workspace = true
actix-multipart.workspace = true
actix-service.workspace = true
@@ -439,37 +439,6 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream5p0Pro,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 5.0 Pro".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(14),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
resolution_options: Some(vec![
CommonResolution::OneK,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
batch_size_min: Some(1),
batch_size_max: Some(8),
batch_size_default: Some(1),
..Default::default()
});
models
}
@@ -515,10 +484,6 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
model: CommonImageModel::Seedream5Lite,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream5p0Pro,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::NanoBanana,
overrides: None,
@@ -9,34 +9,6 @@ const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
const FAKEYOU_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
const STORYTELLER_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
// Runtime override for the CDN base, taking precedence over every constant
// above for all domains and environments. The fully-local dev stack sets
// CDN_BASE_URL=http://localhost:12345 so media URLs resolve against the
// backend's own static /media mount (see add_service_routes) instead of the
// shared r2.dev bucket. Unset (production, and dev-against-remote) keeps the
// compiled-in hosts.
//
// The override must be scheme+host only: some consumers append the rooted
// object path with Url::set_path, which would silently drop a path component
// in the base, so a base with a path is rejected loudly instead.
// Leaked once so get_cdn_host can stay &'static str.
static CDN_BASE_URL_OVERRIDE_STR: Lazy<Option<&'static str>> = Lazy::new(|| {
let base = std::env::var("CDN_BASE_URL")
.ok()
.map(|s| s.trim().trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())?;
let url = Url::parse(&base).expect("CDN_BASE_URL must be a valid absolute URL");
if url.path() != "/" && !url.path().is_empty() {
panic!("CDN_BASE_URL must not include a path component (got '{}'): \
media URL builders overwrite the path", url.path());
}
Some(&*Box::leak(base.into_boxed_str()))
});
static CDN_BASE_URL_OVERRIDE: Lazy<Option<Url>> = Lazy::new(|| {
CDN_BASE_URL_OVERRIDE_STR.map(|s| Url::parse(s).expect("validated above"))
});
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
.expect("should never fail"));
@@ -53,9 +25,6 @@ const STORYTELLER_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELL
// TODO(bt,2025-01-31): Perhaps this should be config driven and configurable at runtime instead of hardcoded.
pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnvironment) -> &'static str {
if let Some(base) = *CDN_BASE_URL_OVERRIDE_STR {
return base;
}
match (media_domain, server_environment) {
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN_STR,
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN_STR,
@@ -65,9 +34,6 @@ pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnviron
}
pub fn new_cdn_url(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Url {
if let Some(url) = CDN_BASE_URL_OVERRIDE.as_ref() {
return url.clone();
}
match (media_domain, server_environment) {
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN.clone(),
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN.clone(),
@@ -10,6 +10,7 @@ use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
use crate::util::generate_api_key::generate_api_key;
@@ -29,6 +30,7 @@ const MAX_DESCRIPTION_LEN: usize = 512;
(status = 200, body = CreateApiKeySuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 403, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
@@ -44,6 +46,14 @@ pub async fn create_api_key_handler(
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// API key creation is gated behind the `api_key` feature flag.
let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
if !feature_flags.can_create_api_key() {
warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str());
return Err(CommonWebError::Forbidden);
}
let name = request.name.trim().to_string();
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
@@ -141,7 +141,6 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
@@ -125,8 +125,7 @@ fn provider_for_model(model: RouterImageModel) -> RouterProvider {
match model {
RouterImageModel::Midjourney7
| RouterImageModel::Midjourney7Niji
| RouterImageModel::Midjourney8
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
| RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro,
_ => RouterProvider::Fal,
}
}
@@ -7,14 +7,13 @@ use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use artcraft_router::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
use uuid_utils::uuid::generate_random_uuid;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
@@ -119,15 +118,14 @@ fn build_execution_request(
})
}
/// Route each image model to the provider that fulfils it. Midjourney and
/// Seedream 5.0 Pro are served via the Seedance2Pro/Kinovi (Volcengine)
/// backend; everything else flows through Fal.
/// Route each image model to the provider that fulfils it. Midjourney is
/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else
/// flows through Fal.
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
match model {
RouterImageModel::Midjourney7
| RouterImageModel::Midjourney7Niji
| RouterImageModel::Midjourney8
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
| RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro,
_ => RouterProvider::Fal,
}
}
@@ -174,21 +172,6 @@ async fn finalize_and_generate(
server_state: &ServerState,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
) -> Result<GenerateImageResponse, CommonWebError> {
// Dev-only: skip the external provider entirely and hand back a synthetic
// Fal enqueue. The handler then inserts a normal pending job (external id
// `fake_…`), which the dev fake-job resolver thread later completes.
if server_state.dev_fake_generation {
let fake_request_id = format!("fake_{}", generate_random_uuid());
info!("DEV_FAKE_GENERATION: skipping image provider call, request_id={}", fake_request_id);
return Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: Some(fake_request_id),
gateway_request_id: None,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: None,
}));
}
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state)?;
@@ -6,13 +6,12 @@ use log::{error, info, warn};
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use artcraft_router::generate::generate_video::generate_video_response::{FalVideoResponsePayload, GenerateVideoResponse};
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use uuid_utils::uuid::generate_random_uuid;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
@@ -221,22 +220,6 @@ async fn upload_and_generate(
kinovi_account: KinoviAccount,
) -> Result<GenerateVideoResponse, CommonWebError> {
// Dev-only: skip the external provider entirely (including draft media
// uploads) and hand back a synthetic Fal enqueue. The handler then inserts
// a normal pending job (external id `fake_…`), which the dev fake-job
// resolver thread later completes.
if server_state.dev_fake_generation {
let fake_request_id = format!("fake_{}", generate_random_uuid());
info!("DEV_FAKE_GENERATION: skipping video provider call, request_id={}", fake_request_id);
return Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: Some(fake_request_id),
gateway_request_id: None,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: None,
}));
}
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state, kinovi_account)?;
@@ -4,7 +4,6 @@ use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::error::Error;
use actix_web::App;
use log::{info, warn};
use server_environment::ServerEnvironment;
use crate::http_server::routes::application_routes::add_application_routes::add_application_routes;
@@ -23,45 +22,13 @@ pub fn add_routes<T, B> (app: App<T>, server_environment: ServerEnvironment) ->
>,
{
let mut app = app;
app = add_legacy_routes(app); // various legacy routes, mostly deprecated
app = add_application_routes(app); // Primary product service area routes
app = add_local_media_route(app, server_environment); // Dev-only static media (fully-local stack)
app = add_service_routes(app); // Essential service routes (status, health, info, etc.)
app
}
/// Dev-only: serve media files from a local directory at /media, mirroring the
/// public bucket's layout, so a fully-local stack (CDN_BASE_URL pointed at this
/// server) can render seeded and fake-generated media without any cloud bucket.
/// LOCAL_MEDIA_ROOT is the bucket root: an object with rooted path
/// /media/{...} is read from {LOCAL_MEDIA_ROOT}/media/{...}.
/// No-op in production and when LOCAL_MEDIA_ROOT is unset.
fn add_local_media_route<T, B> (app: App<T>, server_environment: ServerEnvironment) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
if !server_environment.is_development() {
return app;
}
let root = match std::env::var("LOCAL_MEDIA_ROOT") {
Ok(root) if !root.trim().is_empty() => root,
_ => return app,
};
let media_dir = std::path::Path::new(&root).join("media");
if let Err(err) = std::fs::create_dir_all(&media_dir) {
// Fall back to remote CDN behavior rather than refusing to boot.
warn!("LOCAL_MEDIA_ROOT set but '{}' is not creatable ({}); /media not mounted", media_dir.display(), err);
return app;
}
info!("Dev local media: serving {} at /media", media_dir.display());
app.service(actix_files::Files::new("/media", media_dir))
}
@@ -101,6 +101,10 @@ impl UserSessionFeatureFlags {
pub fn can_use_referrals_program(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::ReferralsProgram)
}
pub fn can_create_api_key(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::ApiKey)
}
}
#[cfg(test)]
@@ -55,7 +55,6 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
use crate::startup::setup_metrics::build_metrics;
use crate::state::server_state::ServerState;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::dev_fake_generation_resolver_thread::dev_fake_generation_resolver_thread;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
@@ -149,16 +148,6 @@ async fn main() -> AnyhowResult<()> {
poll_model_token_info_thread(model_token_info_cache_clone, mysql_pool_clone).await;
});
if server_state.dev_fake_generation {
info!("Spawning dev fake-generation resolver thread (DEV_FAKE_GENERATION=true).");
let mysql_pool_clone = server_state.mysql_pool.clone();
tokio_runtime.spawn(async {
dev_fake_generation_resolver_thread(mysql_pool_clone).await;
});
}
// ==================== Metrics worker ==================== //
let (metrics_collector, maybe_metrics_worker) =
@@ -9,7 +9,7 @@ use cloud_storage::legacy_bucket_client::LegacyBucketClient;
use elasticsearch::http::transport::Transport;
use elasticsearch::Elasticsearch;
use errors::AnyhowResult;
use log::{info, warn};
use log::info;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use mysql_queries::mediators::badge_granter::BadgeGranter;
@@ -124,38 +124,20 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
let s3_compatible_endpoint_url = easyenv::get_env_string_or_default("S3_COMPATIBLE_ENDPOINT_URL", "https://storage.googleapis.com");
let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5));
// NB: read here (before the bucket clients) rather than at first use below —
// the fully-local dev storage mode is gated on the environment.
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let private_bucket_client = LegacyBucketClient::create(
&access_key, &secret_key, &region_name, &private_bucket_name,
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
)?;
// Dev-only: when LOCAL_MEDIA_ROOT is set (the fully-local dev stack), back
// all bucket "uploads" with the local filesystem instead of S3/R2. Objects
// land under the same directory the dev server serves at /media, so
// uploaded media is immediately viewable via the CDN_BASE_URL override.
// Production and dev-against-real-buckets are unaffected (env-gated).
let maybe_local_media_root = if server_environment.is_development() {
std::env::var("LOCAL_MEDIA_ROOT").ok().filter(|root| !root.trim().is_empty())
} else {
None
};
if let Some(root) = maybe_local_media_root.as_deref() {
warn!("Local filesystem storage mode is ON: bucket uploads write under {} (LOCAL_MEDIA_ROOT).", root);
}
let public_bucket_client = LegacyBucketClient::create(
&access_key, &secret_key, &region_name, &public_bucket_name,
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
)?;
let make_bucket_client = |bucket_name: &str| -> AnyhowResult<LegacyBucketClient> {
match maybe_local_media_root.as_deref() {
Some(root) => Ok(LegacyBucketClient::create_local(root, bucket_name, None)),
None => LegacyBucketClient::create(
&access_key, &secret_key, &region_name, bucket_name,
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
),
}
};
let private_bucket_client = make_bucket_client(&private_bucket_name)?;
let public_bucket_client = make_bucket_client(&public_bucket_name)?;
let auto_gc_bucket_client = make_bucket_client(&gc_enabled_public_bucket_name)?;
let auto_gc_bucket_client = LegacyBucketClient::create(
&access_key, &secret_key, &region_name, &gc_enabled_public_bucket_name,
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
)?;
// In-Memory Caches
let voice_list_cache_ttl = easyenv::get_env_duration_seconds_or_default("VOICE_LIST_CACHE_TTL_SECONDS", Duration::from_secs(60));
@@ -193,6 +175,9 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
let health_check_status = HealthCheckStatus::new();
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
.ok_or(anyhow!("invalid server environment"))?;
let (pager, pager_worker, paging_flags) = build_pager(server_environment, server_hostname);
let service_feature_flags = setup_static_feature_flags(paging_flags)?;
@@ -207,15 +192,6 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
let inference_providers = setup_inference_providers()?;
// Dev-only fake generation (see the field docs on ServerState). The
// environment gate is enforced here so the flag can never arm in production
// even if the env var leaks into a deployed config.
let dev_fake_generation = server_environment.is_development()
&& easyenv::get_env_bool_or_default("DEV_FAKE_GENERATION", false);
if dev_fake_generation {
warn!("DEV_FAKE_GENERATION is ON: omni_gen requests will not contact real providers.");
}
let resend_api_key = easyenv::get_env_string_required("RESEND_API_KEY")?;
let startup_time = Utc::now();
@@ -237,7 +213,6 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
hostname: server_hostname.to_string(),
startup_time,
server_environment,
dev_fake_generation,
flags: service_feature_flags,
third_party_url_redirector,
health_check_status,
@@ -57,13 +57,6 @@ pub struct ServerState {
/// Knowing if we're in production will allow us to turn off development-only functionalities.
pub server_environment: ServerEnvironment,
/// Dev-only (DEV_FAKE_GENERATION=true, forced off outside Development):
/// omni_gen pipelines skip the external provider call and insert a pending
/// job with a synthetic `fake_` external id, which the dev fake-job
/// resolver thread later completes or fails. Lets a local stack with dummy
/// provider secrets exercise the full submit -> pending -> resolve flow.
pub dev_fake_generation: bool,
/// Feature flags will allow us to restart the service with different conditions embedded in the code.
pub flags: StaticFeatureFlags,
@@ -1,194 +0,0 @@
use std::path::Path;
use std::time::Duration;
use cloud_storage::legacy_bucket_client::local_object_disk_path;
use log::{error, info};
use sqlx::MySqlPool;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use bucket_paths::path_conventions::video_thumbnail_suffixes::{VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX, VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX};
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::web::list_pending_fake_generation_jobs::{list_pending_fake_generation_jobs, PendingFakeGenerationJob};
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{
mark_generic_inference_job_successfully_done_by_token_with_executor,
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs,
};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::generate::common::job_failure_test::test_synthetic_failure_reason;
/// Placeholder result assets, relative to the repo root (the server's working
/// directory — its config search path already depends on that).
const FAKE_IMAGE_RESULT_SOURCE: &str = "test_data/image/juno.jpg";
const FAKE_VIDEO_RESULT_SOURCE: &str = "test_data/video/mp4/golden_sun_garoh.mp4";
const JOBS_PER_TICK: u32 = 25;
/// Dev-only companion to DEV_FAKE_GENERATION (see ServerState): polls for
/// pending jobs whose external id is `fake_…` and, once they are older than
/// DEV_FAKE_GENERATION_RESOLVE_SECS, resolves them the way a real provider
/// callback would — a `media_files` row backed by a placeholder asset under
/// LOCAL_MEDIA_ROOT for success, or a synthetic failure when the prompt
/// contains the `simulate_artcraft_failure` / `test_artcraft_failure`
/// triggers (with an optional FrontendFailureCategory word, same as the
/// legacy handlers).
pub async fn dev_fake_generation_resolver_thread(mysql_pool: MySqlPool) {
let resolve_after = easyenv::get_env_duration_seconds_or_default(
"DEV_FAKE_GENERATION_RESOLVE_SECS", Duration::from_secs(6));
let poll_interval = easyenv::get_env_duration_seconds_or_default(
"DEV_FAKE_GENERATION_POLL_SECS", Duration::from_secs(2));
info!("Dev fake-generation resolver running: jobs resolve after ~{}s (poll every {}s).",
resolve_after.as_secs(), poll_interval.as_secs());
loop {
tokio::time::sleep(poll_interval).await;
let jobs = match list_pending_fake_generation_jobs(
&mysql_pool,
resolve_after.as_secs() as u32,
JOBS_PER_TICK,
).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Dev fake-generation resolver failed to list pending jobs: {:?}", err);
continue;
}
};
for job in jobs {
let job_token = job.job_token.clone();
if let Err(err) = resolve_job(&mysql_pool, &job, resolve_after).await {
error!("Dev fake-generation resolver failed on job {}: {:?}", job_token.as_str(), err);
// Fail the job so the frontend sees a terminal state instead of an
// eternally-pending spinner (and so we don't retry it every tick).
let mark_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &mysql_pool,
job_token: &job_token,
maybe_public_failure_reason: Some("Dev fake generation could not produce a result."),
internal_debugging_failure_reason: &format!("dev fake resolver error: {:?}", err),
maybe_frontend_failure_category: None,
}).await;
if let Err(mark_err) = mark_result {
error!("Also failed to mark job {} failed: {:?}", job_token.as_str(), mark_err);
}
}
}
}
}
async fn resolve_job(
mysql_pool: &MySqlPool,
job: &PendingFakeGenerationJob,
simulated_duration: Duration,
) -> AnyhowResult<()> {
let prompt = job.maybe_positive_prompt.as_deref().unwrap_or("");
if let Some(failure) = test_synthetic_failure_reason(prompt) {
mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: failure.frontend_failure_message.as_deref(),
internal_debugging_failure_reason: "DEV_FAKE_GENERATION synthetic failure (prompt trigger)",
maybe_frontend_failure_category: Some(failure.frontend_failure_category),
}).await?;
info!("Dev fake-generation: job {} failed synthetically ({:?}).",
job.job_token.as_str(), failure.frontend_failure_category);
return Ok(());
}
let media_token = insert_fake_result_media(mysql_pool, job).await?;
mark_generic_inference_job_successfully_done_by_token_with_executor(
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs {
executor: mysql_pool,
token: &job.job_token,
maybe_entity_type: Some(InferenceResultType::MediaFile),
maybe_entity_token: Some(media_token.as_str()),
total_job_duration: Some(simulated_duration),
inference_duration: Some(simulated_duration),
},
).await?;
info!("Dev fake-generation: job {} completed with media file {}.",
job.job_token.as_str(), media_token.as_str());
Ok(())
}
/// Copy a placeholder asset to a fresh bucket path under LOCAL_MEDIA_ROOT and
/// insert the `media_files` row, mirroring what the Fal webhook success path
/// does with a real provider result (minus the R2 upload).
async fn insert_fake_result_media(
mysql_pool: &MySqlPool,
job: &PendingFakeGenerationJob,
) -> AnyhowResult<MediaFileToken> {
let is_video = job.inference_category.contains("video");
let (source_path, extension, media_class, media_type, mime_type, product_category) = if is_video {
(FAKE_VIDEO_RESULT_SOURCE, ".mp4", MediaFileClass::Video, MediaFileType::Mp4,
"video/mp4", MediaFileOriginProductCategory::VideoGeneration)
} else {
(FAKE_IMAGE_RESULT_SOURCE, ".jpg", MediaFileClass::Image, MediaFileType::Jpg,
"image/jpeg", MediaFileOriginProductCategory::ImageGeneration)
};
let media_root = std::env::var("LOCAL_MEDIA_ROOT")
.ok()
.filter(|root| !root.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!(
"LOCAL_MEDIA_ROOT must be set for DEV_FAKE_GENERATION results to be viewable"))?;
let file_bytes = tokio::fs::read(source_path).await
.map_err(|err| anyhow::anyhow!(
"cannot read placeholder asset {} (server must run from the repo root): {}", source_path, err))?;
let bucket_path = MediaFileBucketPath::generate_new(Some("fake_"), Some(extension));
let disk_path = local_object_disk_path(Path::new(&media_root), &bucket_path.get_full_object_path_str())?;
if let Some(parent) = disk_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&disk_path, &file_bytes).await?;
if is_video {
// The video thumbnail job never runs locally, so drop the thumbnail
// siblings the gallery expects next to the object. The animated "gif" is
// really JPEG bytes — browsers decode <img> content by magic bytes, and a
// still preview is plenty for dev.
let thumb_bytes = tokio::fs::read(FAKE_IMAGE_RESULT_SOURCE).await?;
let base = disk_path.to_string_lossy().to_string();
tokio::fs::write(format!("{base}{VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX}"), &thumb_bytes).await?;
tokio::fs::write(format!("{base}{VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX}"), &thumb_bytes).await?;
}
let checksum = sha256_hash_bytes(&file_bytes)?;
let media_token = MediaFileInsertBuilder::new()
.checksum_sha2(&checksum)
.creator_ip_address("127.0.0.1")
.file_size_bytes(file_bytes.len() as u64)
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.media_file_class(media_class)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(product_category)
.media_file_type(media_type)
.mime_type(mime_type)
.public_bucket_directory_hash(&bucket_path)
.insert_pool(mysql_pool)
.await
.map_err(|err| anyhow::anyhow!("failed to insert fake result media file: {:?}", err))?;
Ok(media_token)
}
@@ -1,4 +1,3 @@
pub mod db_health_checker_thread;
pub mod dev_fake_generation_resolver_thread;
pub mod poll_ip_banlist_thread;
pub mod poll_model_token_info_thread;
pub mod poll_model_token_info_thread;
@@ -116,12 +116,8 @@ export function getMediaThumbnailUrl(
}
}
// 3D (mesh/splat, plus legacy pre-split "dimensional"): use cover image URL
if (
item.media_class === "mesh" ||
item.media_class === "splat" ||
item.media_class === "dimensional"
) {
// 3D/dimensional: use cover image URL
if (item.media_class === "dimensional") {
if ("cover_image" in item && item.cover_image) {
if (item.cover_image.maybe_cover_image_public_bucket_url) {
return item.cover_image.maybe_cover_image_public_bucket_url;
@@ -169,6 +169,60 @@ export class UsersApi extends ApiManager {
}));
}
public async RequestPasswordReset({
email,
}: {
email: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/request_password_reset`;
return await this.post<
{ email: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { email },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ResetPassword({
token,
password,
passwordConfirmation,
}: {
token: string;
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/reset_password`;
return await this.post<
{ token: string; password: string; password_confirmation: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: {
token,
password,
password_confirmation: passwordConfirmation,
},
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ChangePassword({
password,
passwordConfirmation,
@@ -28,8 +28,6 @@ interface MediaCardProps {
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatDuration(ms: number): string {
@@ -120,9 +118,7 @@ export function MediaCard({
{/* Top-left: media class + upload indicator */}
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{mediaClass === "dimensional" || mediaClass === "mesh" || mediaClass === "splat"
? "3D"
: mediaClass || "Unknown"}
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
</span>
{isUserUpload && (
<span
@@ -41,15 +41,11 @@ const classLabels: Record<string, string> = {
image: "Image",
video: "Video",
dimensional: "3D",
mesh: "3D Object",
splat: "3D World",
};
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatModelType(model: string): string {
@@ -26,9 +26,7 @@ const MEDIA_CLASS_OPTIONS = [
{ value: "", label: "All" },
{ value: "image", label: "Image" },
{ value: "video", label: "Video" },
// "dimensional" is the deprecated pre-split class; rows persist until the
// backfill lands, so the 3D filter matches all three classes.
{ value: "mesh,splat,dimensional", label: "3D" },
{ value: "dimensional", label: "3D" },
] as const;
const PAGE_SIZE = 24;
@@ -76,8 +74,7 @@ export function ExploreMedia() {
const resp = await api.ListMediaFiles({
page_size: PAGE_SIZE,
cursor,
filter_media_classes:
filterMediaClasses || "image,video,mesh,splat,dimensional",
filter_media_classes: filterMediaClasses || "image,video,dimensional",
filter_media_type: filterMediaType || undefined,
include_user_uploads: includeUserUploads || undefined,
});
@@ -46,7 +46,7 @@ export function UserCreations() {
const resp = await mediaApi.ListUserMediaFiles(username, {
page_index: pageIndex,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
});
if (cancelledRef.current) return;
@@ -84,7 +84,7 @@ export function UserCreations() {
mediaApi.ListUserMediaFiles(username, {
page_index: 0,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
}),
]);
@@ -174,7 +174,7 @@ export function UserProfile() {
modApi.UserLookup(username),
mediaApi.ListUserMediaFiles(username, {
page_size: 24,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
}),
]);
@@ -3,7 +3,6 @@
"version": "0.0.1",
"private": true,
"dependencies": {
"jszip": "^3.10.1",
"react-aria": "^3.48.0",
"react-masonry-css": "^1.0.16"
}
+7 -13
View File
@@ -18,7 +18,6 @@ import CreateAudio from "../pages/create-audio";
import CreateObject from "../pages/create-object";
import CreateWorld from "../pages/create-world";
import CreateVFX from "../pages/create-vfx";
import FrameExtractor from "../pages/frame-extractor";
import PageScene from "../pages/pagescene";
import PageDraw from "../pages/pagedraw";
import VideoEditorPage from "../pages/video-editor";
@@ -161,11 +160,15 @@ export function App() {
<Route path="/create-object" element={<CreateObject />} />
<Route path="/create-world" element={<CreateWorld />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/frame-extractor" element={<FrameExtractor />} />
<Route path="/moodboard" element={<MoodboardPage />} />
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/edit-image" element={<PageDraw />} />
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Welcome is public so it stays reachable right after signup
@@ -176,21 +179,12 @@ export function App() {
{/* Protected — sign-in required (user-owned content / billing flows) */}
<Route element={<RequireAuth />}>
{/* Video projects persist server-side, so the whole editor is
sign-in gated: logged-out visits bounce to /login?from=...
and return here after auth. */}
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
<Route path="/library/folders" element={<Library />} />
{/* `:slug` is a media-class filter (images/videos/meshes), a
folder token (prefixed `folder_`), the static `tags` tab, or
a tag token (prefixed `tag_`); the page disambiguates. */}
{/* `:slug` is a media-class filter (images/videos/meshes) OR a
folder token (prefixed `folder_`); the page disambiguates. */}
<Route path="/library/:slug" element={<Library />} />
<Route path="/referrals" element={<Referrals />} />
<Route path="/onboarding" element={<Onboarding />} />
@@ -7,11 +7,7 @@ import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "./GalleryViewToggle";
import { GalleryViewToggle } from "./GalleryViewToggle";
import {
MobileCreateTabsProvider,
type MobileCreateTab,
@@ -27,12 +23,6 @@ interface CreateMediaPageShellProps {
authChecked: boolean;
// Content
hasContent: boolean;
// Video page only: playing/still toggle for video preview thumbnails,
// rendered next to the grid/list toggle on the mobile History tab.
showAutoplayToggle?: boolean;
// Multi-select + batch download toggle, rendered in the same mobile
// History-tab cluster (the desktop toggle lives in the TopBar).
showSelectToggle?: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
@@ -64,8 +54,6 @@ export function CreateMediaPageShell({
description,
authChecked,
hasContent,
showAutoplayToggle = false,
showSelectToggle = false,
emptyStateTitle,
emptyStateSubtitle,
emptyStateCta,
@@ -104,26 +92,18 @@ export function CreateMediaPageShell({
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<Seo title={title} description={description} />
{/* Flex (not absolute) keeps the toggle cluster from overlapping
the tabs — the tabs stay centered via the equal flex-1 sides and
just shift left if the cluster outgrows its half. */}
<div className="flex items-center border-b border-ui-panel-border px-3 py-2">
<div className="flex-1" />
<div className="relative flex items-center justify-center border-b border-ui-panel-border px-3 py-2">
<TabSelector
tabs={MOBILE_TABS}
activeTab={mobileTab}
onTabChange={(id) => setMobileTab(id as MobileCreateTab)}
className="w-auto shrink-0"
className="w-auto"
/>
<div className="flex flex-1 items-center justify-end gap-1.5">
{mobileTab === "history" && hasContent && (
<>
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
<GalleryViewToggle />
</>
)}
</div>
{mobileTab === "history" && hasContent && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<GalleryViewToggle />
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -1,101 +0,0 @@
import { useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import {
SelectionActionBar,
useGallerySelectionStore,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import { useIsMobile } from "../ui/use-mobile";
// Floating bar shown while the generation feed is in select mode:
// "Download selected" zips the chosen items client-side. Centered within the
// content area next to the sidebar; on desktop it sits above the floating
// promptbox via the measured `bottomOffset`, on mobile (History tab — no
// promptbox) it uses a fixed offset like the library's bulk bar.
export function GallerySelectionDownloadBar({
allItems,
bottomOffset,
}: {
allItems: GalleryItem[];
bottomOffset?: number;
}) {
const isMobile = useIsMobile();
const ids = useGallerySelectionStore((s) => s.ids);
const setActive = useGallerySelectionStore((s) => s.setActive);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const isDownloading = progress !== null;
const selectedItems = useMemo(() => {
const byId = new Map(allItems.map((it) => [it.id, it] as const));
return Array.from(ids)
.map((id) => byId.get(id))
.filter(
(it): it is GalleryItem & { fullImage: string } => !!it?.fullImage,
);
}, [allItems, ids]);
const handleDownload = async () => {
if (isDownloading || selectedItems.length === 0) return;
setProgress({ done: 0, total: selectedItems.length });
try {
const { succeeded, failed } = await downloadItemsAsZip(
selectedItems.map((it) => ({
id: it.id,
url: it.fullImage,
mediaClass: it.mediaClass,
})),
{ onProgress: (done, total) => setProgress({ done, total }) },
);
if (failed === 0) {
toast.success(
`Downloaded ${succeeded} ${succeeded === 1 ? "file" : "files"}`,
);
} else if (succeeded > 0) {
toast.error(
`${failed} of ${succeeded + failed} files failed to download`,
);
} else {
toast.error("Could not download the selected files.");
}
if (succeeded > 0) setActive(false);
} finally {
setProgress(null);
}
};
return (
<SelectionActionBar
className={isMobile ? "bottom-20" : ""}
// Center within the content area (viewport minus the app sidebar); on
// desktop, sit above the fixed promptbox whatever its measured height.
style={{
left: "var(--ac-sidebar-offset, 0px)",
...(isMobile ? {} : { bottom: bottomOffset ?? 16 }),
}}
>
<button
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && progress
? `Downloading ${progress.done}/${progress.total}…`
: "Download"}
</button>
</SelectionActionBar>
);
}
@@ -1,6 +1,2 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
GalleryViewToggle,
GalleryAutoplayToggle,
GallerySelectToggle,
} from "@storyteller/ui-generation-list";
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
@@ -1,9 +1,6 @@
import { useEffect, useMemo } from "react";
import { useGallerySelectionStore } from "@storyteller/ui-generation-list";
import { useGalleryViewStore } from "../../lib/gallery-view-store";
import { GenerationGalleryGrid } from "./GenerationGalleryGrid";
import { GenerationGalleryList } from "./GenerationGalleryList";
import { GallerySelectionDownloadBar } from "./GallerySelectionDownloadBar";
import type { GenerationGalleryProps } from "./types";
// Renders the generation feed in whichever layout the user picked via the
@@ -11,34 +8,9 @@ import type { GenerationGalleryProps } from "./types";
// rendering the grid directly on the create pages.
export function GenerationGallery(props: GenerationGalleryProps) {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const { selectable, selectionBarBottomOffset, newlyCompletedItems, galleryItems } =
props;
// The selection store is global (like the view-mode store) — drop select
// mode when the feed unmounts so it can't leak onto other pages.
useEffect(() => {
if (!selectable) return;
return () => useGallerySelectionStore.getState().setActive(false);
}, [selectable]);
const allItems = useMemo(
() => [...newlyCompletedItems, ...galleryItems],
[newlyCompletedItems, galleryItems],
);
return (
<>
{viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
)}
{selectable && (
<GallerySelectionDownloadBar
allItems={allItems}
bottomOffset={selectionBarBottomOffset}
/>
)}
</>
return viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
);
}
@@ -13,7 +13,6 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { useGalleryItemActions } from "./useGalleryItemActions";
@@ -37,10 +36,7 @@ export function GenerationGalleryGrid({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationGridView
inProgressJobs={inProgressJobs}
@@ -58,17 +54,10 @@ export function GenerationGalleryGrid({
renderGalleryActions={(item) => (
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
)}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
// Recreate affordance for pending + failed cards. The pending card shows a
// hover-revealed icon button by the prompt; the failed card a labeled button
// next to Dismiss.
@@ -13,7 +13,6 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { useRecreateFromPromptToken } from "../../lib/recreate";
@@ -41,10 +40,7 @@ export function GenerationGalleryList({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationListView
inProgressJobs={inProgressJobs}
@@ -67,17 +63,10 @@ export function GenerationGalleryList({
/>
)}
onCopyPromptResult={handleCopyResult}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
function handleCopyResult(success: boolean) {
if (success) {
toast.success("Prompt copied");
@@ -1,7 +1,7 @@
export { GenerationGallery } from "./GenerationGallery";
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
export { GenerationGalleryList } from "./GenerationGalleryList";
export { GalleryViewToggle, GalleryAutoplayToggle } from "./GalleryViewToggle";
export { GalleryViewToggle } from "./GalleryViewToggle";
export { useGalleryData, type GalleryItem } from "./useGalleryData";
export { useSessionMediaByClass } from "@storyteller/ui-generation-list";
export {
@@ -16,10 +16,4 @@ export interface GenerationGalleryProps {
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
enableMakeVideo?: boolean;
/** Enables the multi-select + batch download flow (select toggle in the
* TopBar, checkboxes on completed items, floating download bar). */
selectable?: boolean;
/** Pixel offset that keeps the floating download bar above the page's
* fixed prompt box (pass the measured promptbox height + gap). */
selectionBarBottomOffset?: number;
}
@@ -15,12 +15,12 @@ import {
faLink,
faPause,
faPencil,
faPhotoFilm,
faPlay,
faSpinnerThird,
faTrashCan,
faUser,
faVideo,
faWaveformLines,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
@@ -34,7 +34,6 @@ import {
getProviderIconByName,
} from "@storyteller/model-list";
import { downloadMediaFile } from "../../lib/download-media";
import { TagsSection } from "../tags/TagsSection";
import {
formatAspectRatio,
formatDuration,
@@ -77,7 +76,6 @@ export interface LightboxDetailsProps {
onRecreate?: () => void;
onMakeVideo?: () => void;
onEditOnCanvas?: () => void;
onExtractFrames?: () => void;
onDelete?: () => void;
showDownloadAppCta?: boolean;
}
@@ -94,7 +92,6 @@ export function LightboxDetails({
onRecreate,
onMakeVideo,
onEditOnCanvas,
onExtractFrames,
onDelete,
showDownloadAppCta,
}: LightboxDetailsProps) {
@@ -433,8 +430,6 @@ export function LightboxDetails({
</div>
</div>
)}
<TagsSection mediaToken={mediaToken} creator={creator} />
</>
)}
</div>
@@ -497,16 +492,6 @@ export function LightboxDetails({
Edit on Canvas
</Button>
)}
{onExtractFrames && (
<Button
icon={faPhotoFilm}
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
variant="secondary"
onClick={onExtractFrames}
>
Extract Frames
</Button>
)}
{onRecreate && promptData.hasToken && (
<Button
icon={faArrowRotateRight}
@@ -32,7 +32,6 @@ import {
applyMakeVideoFromImage,
applyRecreateFromMediaToken,
} from "../../lib/recreate";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -81,7 +80,6 @@ export function Lightbox({
showBatchCarousel = true,
}: LightboxProps) {
const navigate = useNavigate();
const soundEnabled = useLightboxSoundStore((s) => s.soundEnabled);
const [mediaLoaded, setMediaLoaded] = useState(false);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
const [creator, setCreator] = useState<UserInfo | null>(null);
@@ -352,12 +350,6 @@ export function Lightbox({
applyEditOnCanvasFromImage(selectedMediaToken, selectedImageUrl, navigate);
}, [selectedMediaToken, selectedImageUrl, canEditOnCanvas, navigate, onClose]);
const handleExtractFrames = useCallback(() => {
if (!selectedMediaToken) return;
onClose();
navigate(`/frame-extractor?media=${selectedMediaToken}`);
}, [selectedMediaToken, navigate, onClose]);
return (
<>
<Modal
@@ -387,7 +379,7 @@ export function Lightbox({
controls
loop
autoPlay
muted={!soundEnabled}
muted
playsInline
disablePictureInPicture
controlsList="nodownload noplaybackrate nofullscreen"
@@ -397,22 +389,6 @@ export function Lightbox({
const el = e.currentTarget;
setMediaWidth(el.videoWidth);
setMediaHeight(el.videoHeight);
// Restore the user's last volume (getState: subscribing
// would re-render on every tick of a volume drag).
el.volume = useLightboxSoundStore.getState().volume;
// If the browser blocked unmuted autoplay, fall back to
// muted playback rather than leaving the video frozen.
if (soundEnabled && el.paused) {
el.play().catch(() => {
el.muted = true;
el.play().catch(() => {});
});
}
}}
onVolumeChange={(e) => {
useLightboxSoundStore
.getState()
.setVolume(e.currentTarget.volume);
}}
ref={(el) => {
if (el) {
@@ -425,13 +401,11 @@ export function Lightbox({
</video>
) : isAudio ? (
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<FontAwesomeIcon
icon={faMusic}
className="text-2xl text-white/70"
/>
</div>
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-white/[0.04] px-2 py-8">
<FontAwesomeIcon
icon={faMusic}
className="mx-auto mb-5 block text-3xl text-white/20"
/>
<WaveformAudioPlayer
key={selectedImageUrl}
src={selectedImageUrl}
@@ -565,9 +539,6 @@ export function Lightbox({
? handleEditOnCanvas
: undefined
}
onExtractFrames={
isVideo && selectedMediaToken ? handleExtractFrames : undefined
}
onDelete={
selectedMediaToken
? () => setConfirmDeleteOpen(true)
@@ -89,13 +89,11 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while any creation is pending (even with the modal
// closed), so the pending card gets cleaned up and — once the character is
// active — the mention store learns its real character token. The create
// response only carries an inference job token, so @-mentions must wait for
// the server list to include the character before it can be referenced.
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
useEffect(() => {
if (pendingCharacters.length === 0) return;
if (!isOpen || pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
@@ -114,25 +112,14 @@ export const CharactersModal = ({
return true;
}),
);
if (resolved) {
const store = useCharactersStore.getState();
store.setCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
store.setLoaded(true);
setRefreshKey((k) => k + 1);
}
if (resolved) setRefreshKey((k) => k + 1);
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters.length]);
}, [isOpen, pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
@@ -623,6 +610,7 @@ const NewCharacterView = ({
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
@@ -765,6 +753,11 @@ const NewCharacterView = ({
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
onCreated({
name: name.trim(),
previewUrl: uploadedImages[0]!.url,
@@ -32,7 +32,6 @@ interface MediaReferenceRowProps {
onReferenceAudiosChange: (audios: RefAudio[]) => void;
maxAudioCount: number;
maxAudioRefDuration: number;
onPickAudioFromLibrary?: () => void;
className?: string;
}
@@ -48,7 +47,6 @@ export const MediaReferenceRow = ({
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
onPickAudioFromLibrary,
className,
}: MediaReferenceRowProps) => {
const videoInputRef = useRef<HTMLInputElement>(null);
@@ -213,7 +211,7 @@ export const MediaReferenceRow = ({
/>
<div
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl",
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-none",
className,
)}
onMouseDown={(e) => e.stopPropagation()}
@@ -305,11 +303,7 @@ export const MediaReferenceRow = ({
</div>
)}
{canAddAudio && (
<AddButton
onUpload={() => audioInputRef.current?.click()}
onPickFromLibrary={onPickAudioFromLibrary}
title="Add audio"
/>
<AddButton onUpload={() => audioInputRef.current?.click()} />
)}
</div>
</div>
@@ -16,28 +16,12 @@ import {
faUserGroup,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { GenerateIconButton } from "@storyteller/ui-button";
import { GenerateButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
KeyframeCards,
PromptClearAllButton,
ReferenceDeck,
useDeckMedia,
type DeckAddAction,
type DeckItem,
} from "@storyteller/ui-promptbox";
import { arrayMove } from "@dnd-kit/sortable";
import { ImagePromptRow } from "./ImagePromptRow";
import { MentionTextarea } from "./MentionTextarea";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./PromptBoxDropZone";
import { toast } from "../toast/toast";
import { getMentionColor, buildMentionColorMap } from "./mention-colors";
import { uploadImage } from "./upload-image";
import { uploadVideo, uploadAudio } from "./upload-media";
import type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
import type { RefImage, MentionItem } from "./types";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import {
PromptFullscreenButton,
@@ -52,6 +36,7 @@ interface PromptBoxProps {
onPromptChange: (prompt: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
submitLabel?: string;
placeholder?: string;
disabled?: boolean;
credits?: number | null;
@@ -76,33 +61,11 @@ interface PromptBoxProps {
// Pick from library
onPickFromLibrary?: () => void;
onPickEndFrameFromLibrary?: () => void;
onPickVideoFromLibrary?: () => void;
onPickAudioFromLibrary?: () => void;
// Clear all references (images, end frame, videos, audios)
onClearAllRefs?: () => void;
// Toolbar clear-all support. The button wipes the prompt and every
// reference the box renders; pages holding extra state outside the box
// (media reference row, secondary prompts) clear it via onClearAllExtras
// and report its presence via hasClearableExtras so the button enables.
onClearAllExtras?: () => void;
hasClearableExtras?: boolean;
// Video/audio references, rendered as cards in the reference deck (video
// page in reference mode, world page's guide video, audio page's tracks).
videoRefsSupported?: boolean;
referenceVideos?: RefVideo[];
onReferenceVideosChange?: (videos: RefVideo[]) => void;
maxVideoCount?: number;
maxVideoRefDuration?: number;
audioRefsSupported?: boolean;
referenceAudios?: RefAudio[];
onReferenceAudiosChange?: (audios: RefAudio[]) => void;
maxAudioCount?: number;
maxAudioRefDuration?: number;
// Always-visible named slot cards rendered beside the reference deck
// (object page's multi-view angles + input mesh), built from DeckSlotCard.
referenceSlots?: ReactNode;
// Media reference row (video/audio refs, rendered between image row and prompt)
mediaReferenceRow?: ReactNode;
// Model selector (rendered above the toolbar, typically hidden on desktop via lg:hidden)
modelSelector?: ReactNode;
@@ -127,6 +90,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
onPromptChange,
onSubmit,
isSubmitting,
submitLabel = "Generate",
placeholder = "Describe what you want...",
disabled,
credits,
@@ -143,22 +107,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
rightToolbar,
onPickFromLibrary,
onPickEndFrameFromLibrary,
onPickVideoFromLibrary,
onPickAudioFromLibrary,
onClearAllRefs,
onClearAllExtras,
hasClearableExtras,
videoRefsSupported,
referenceVideos = [],
onReferenceVideosChange,
maxVideoCount = 3,
maxVideoRefDuration = 30,
audioRefsSupported,
referenceAudios = [],
onReferenceAudiosChange,
maxAudioCount = 2,
maxAudioRefDuration = 30,
referenceSlots,
mediaReferenceRow,
modelSelector,
secondaryPromptRow,
mentionItems,
@@ -172,6 +122,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const { isFullscreen, openFullscreen, closeFullscreen } =
useFullscreenPrompt();
@@ -197,386 +148,12 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const [mentionIndex, setMentionIndex] = useState(0);
const mentionAnchorRef = useRef<number | null>(null);
const hasMentionItems = (mentionItems?.length ?? 0) > 0;
const deck = useDeckMedia({
referenceImages,
setReferenceImages: onReferenceImagesChange,
// 0 blocks image uploads (incl. via the combined picker) on pages whose
// model only takes audio/video/mesh refs.
maxImages: supportsImagePrompts ? maxImagePromptCount : 0,
setEndFrameImage: onEndFrameImageChange,
referenceVideos,
setReferenceVideos: onReferenceVideosChange,
maxVideos: maxVideoCount,
maxVideoTotalSec: maxVideoRefDuration,
referenceAudios,
setReferenceAudios: onReferenceAudiosChange,
maxAudios: maxAudioCount,
maxAudioTotalSec: maxAudioRefDuration,
uploadImage,
uploadVideo,
uploadAudio,
// Library picking stays with the page-level GalleryModals.
ownGalleryModal: false,
});
// Drag & drop onto the box bounds: files route to the reference kind
// their MIME matches, gated on what the page's model supports. Keyframe
// mode renders no video/audio deck, so those kinds only land in
// reference mode where the user can see (and remove) them.
const isKeyframeMode = !!isVideo && !isReferenceMode;
const dropAcceptsImages =
!!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsVideos =
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
const dropAcceptsAudio =
!isKeyframeMode && !!audioRefsSupported && !!onReferenceAudiosChange;
const handleDroppedFiles = ({ images, videos, audios }: DroppedFiles) => {
if (images.length > 0) {
if (isKeyframeMode) {
// Fill the empty keyframe slots in order: start frame, then end.
const queue = [...images];
const startOpen =
referenceImages.length === 0 && deck.uploadingImages.length === 0;
const endOpen =
!!showEndFrameSection && !endFrameImage && !deck.uploadingEnd;
if (startOpen) deck.processImageFiles([queue.shift()!], "start");
if (endOpen && queue.length > 0) {
deck.processImageFiles([queue.shift()!], "end");
}
if (!startOpen && !endOpen) {
toast.error(
showEndFrameSection
? "Start and end frames are already set"
: "The start frame is already set",
);
}
} else if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImagePromptCount} image reference${maxImagePromptCount === 1 ? "" : "s"}`,
);
} else {
deck.processImageFiles(images, "start");
}
}
if (videos.length > 0) deck.processVideoFiles(videos);
if (audios.length > 0) deck.processAudioFiles(audios);
};
const drop = usePromptBoxDrop({
acceptsImages: dropAcceptsImages,
acceptsVideos: dropAcceptsVideos,
acceptsAudio: dropAcceptsAudio,
onDropFiles: handleDroppedFiles,
});
// Mixed deck items ordered images → videos → audios so the page's
// index-derived @ImageN/@VideoN/@AudioN mention labels stay aligned.
const deckItems: DeckItem[] = useMemo(
() => [
...referenceImages.map((img, i) => ({
id: img.id,
kind: "image" as const,
url: img.url,
previewUrl: img.fullUrl ?? img.url,
name: `Image ${i + 1}`,
})),
...deck.uploadingImages.map((entry, i) => ({
id: entry.id,
kind: "image" as const,
url: entry.previewUrl,
name: `Image ${referenceImages.length + i + 1}`,
uploading: true,
})),
...referenceVideos.map((video, i) => ({
id: video.id,
kind: "video" as const,
url: video.url,
name: `Video ${i + 1}`,
duration: video.duration,
})),
...(deck.uploadingVideo
? [
{
id: deck.uploadingVideo.id,
kind: "video" as const,
url: deck.uploadingVideo.previewUrl,
name: `Video ${referenceVideos.length + 1}`,
uploading: true,
},
]
: []),
...referenceAudios.map((audio, i) => ({
id: audio.id,
kind: "audio" as const,
url: audio.url,
name: `Audio ${i + 1}`,
duration: audio.duration,
})),
...(deck.uploadingAudio
? [
{
id: deck.uploadingAudio.id,
kind: "audio" as const,
name: `Audio ${referenceAudios.length + 1}`,
uploading: true,
},
]
: []),
],
[
referenceImages,
referenceVideos,
referenceAudios,
deck.uploadingImages,
deck.uploadingVideo,
deck.uploadingAudio,
],
);
const deckAddActions: DeckAddAction[] = [];
if (
const isImageRowVisible =
supportsImagePrompts &&
referenceImages.length + deck.uploadingImages.length <
maxImagePromptCount
) {
deckAddActions.push({
key: "upload-image",
label: "Upload",
group: "image",
onSelect: deck.openImageUpload,
});
if (onPickFromLibrary) {
deckAddActions.push({
key: "library-image",
label: isVideo ? "From library" : "Pick from library",
group: "image",
onSelect: onPickFromLibrary,
});
}
}
if (
videoRefsSupported &&
referenceVideos.length < maxVideoCount &&
!deck.uploadingVideo
) {
deckAddActions.push({
key: "upload-video",
label: "Upload",
group: "video",
onSelect: deck.openVideoUpload,
});
if (onPickVideoFromLibrary) {
deckAddActions.push({
key: "library-video",
label: "From library",
group: "video",
onSelect: onPickVideoFromLibrary,
});
}
}
if (
audioRefsSupported &&
referenceAudios.length < maxAudioCount &&
!deck.uploadingAudio
) {
deckAddActions.push({
key: "upload-audio",
label: "Upload",
group: "audio",
onSelect: deck.openAudioUpload,
});
if (onPickAudioFromLibrary) {
deckAddActions.push({
key: "library-audio",
label: "From library",
group: "audio",
onSelect: onPickAudioFromLibrary,
});
}
}
(isVideo || showImagePrompts || referenceImages.length > 0);
const handleRemoveDeckItem = (id: string) => {
if (referenceImages.some((img) => img.id === id)) {
onReferenceImagesChange(referenceImages.filter((img) => img.id !== id));
} else if (referenceVideos.some((video) => video.id === id)) {
onReferenceVideosChange?.(
referenceVideos.filter((video) => video.id !== id),
);
} else if (referenceAudios.some((audio) => audio.id === id)) {
onReferenceAudiosChange?.(
referenceAudios.filter((audio) => audio.id !== id),
);
}
};
const firstFrameItem: DeckItem | undefined = referenceImages[0]
? {
id: referenceImages[0].id,
kind: "image",
url: referenceImages[0].url,
previewUrl: referenceImages[0].fullUrl ?? referenceImages[0].url,
name: "First frame",
}
: deck.uploadingImages[0]
? {
id: deck.uploadingImages[0].id,
kind: "image",
url: deck.uploadingImages[0].previewUrl,
name: "First frame",
uploading: true,
}
: undefined;
const lastFrameItem: DeckItem | undefined = endFrameImage
? {
id: endFrameImage.id,
kind: "image",
url: endFrameImage.url,
previewUrl: endFrameImage.fullUrl ?? endFrameImage.url,
name: "Last frame",
}
: deck.uploadingEnd
? {
id: deck.uploadingEnd.id,
kind: "image",
url: deck.uploadingEnd.previewUrl,
name: "Last frame",
uploading: true,
}
: undefined;
const hasAttachedRefs =
referenceImages.length > 0 ||
!!endFrameImage ||
referenceVideos.length > 0 ||
referenceAudios.length > 0 ||
!!hasClearableExtras;
const hasClearableContent = prompt.length > 0 || hasAttachedRefs;
const handleClearAll = () => {
onPromptChange("");
// Prefer the page's single-shot clear (pages that keep all refs in one
// state object need it to avoid stale-closure partial updates).
if (onClearAllRefs) {
onClearAllRefs();
} else {
onReferenceImagesChange([]);
onEndFrameImageChange?.(undefined);
onReferenceVideosChange?.([]);
onReferenceAudiosChange?.([]);
}
onClearAllExtras?.();
};
const handleSwapFrames = () => {
const first = referenceImages[0];
if (!first || !endFrameImage) return;
onReferenceImagesChange([endFrameImage]);
onEndFrameImageChange?.(first);
};
// Left-of-textarea reference widget: image deck, keyframe cards, or the
// mixed deck depending on page/mode.
const renderReferenceWidget = (alwaysExpanded?: boolean) => {
if (isKeyframeMode) {
if (!supportsImagePrompts) return null;
return (
<KeyframeCards
firstFrame={firstFrameItem}
lastFrame={lastFrameItem}
showLastFrame={!!showEndFrameSection}
onFirstAddActions={[
{
key: "upload-first",
label: "Upload",
onSelect: deck.openImageUpload,
},
...(onPickFromLibrary
? [
{
key: "library-first",
label: "Pick from library",
onSelect: onPickFromLibrary,
},
]
: []),
]}
onLastAddActions={[
{
key: "upload-last",
label: "Upload",
onSelect: deck.openEndUpload,
},
...(onPickEndFrameFromLibrary
? [
{
key: "library-last",
label: "Pick from library",
onSelect: onPickEndFrameFromLibrary,
},
]
: []),
]}
onRemoveFirst={() => onReferenceImagesChange([])}
onRemoveLast={() => onEndFrameImageChange?.(undefined)}
onSwap={handleSwapFrames}
/>
);
}
if (!supportsImagePrompts && !videoRefsSupported && !audioRefsSupported) {
return null;
}
const totalVideoRefSeconds = referenceVideos.reduce(
(sum, video) => sum + video.duration,
0,
);
const totalAudioRefSeconds = referenceAudios.reduce(
(sum, audio) => sum + audio.duration,
0,
);
const groupHints: Record<string, string> = {};
if (supportsImagePrompts) {
groupHints.image = `${referenceImages.length}/${maxImagePromptCount}`;
}
// A non-finite duration cap means "no limit" — show counts only.
if (videoRefsSupported) {
groupHints.video =
`${referenceVideos.length}/${maxVideoCount}` +
(isFinite(maxVideoRefDuration)
? ` · ${totalVideoRefSeconds}/${maxVideoRefDuration}s`
: "");
}
if (audioRefsSupported) {
groupHints.audio =
`${referenceAudios.length}/${maxAudioCount}` +
(isFinite(maxAudioRefDuration)
? ` · ${totalAudioRefSeconds}/${maxAudioRefDuration}s`
: "");
}
return (
<ReferenceDeck
items={deckItems}
canAdd={deckAddActions.length > 0}
addActions={deckAddActions}
addMenuGroupHints={groupHints}
onAddClick={
supportsImagePrompts || videoRefsSupported || audioRefsSupported
? deck.openAnyUpload
: undefined
}
onRemove={handleRemoveDeckItem}
onReorderImages={(from, to) =>
onReferenceImagesChange(arrayMove(referenceImages, from, to))
}
onClearAll={onClearAllRefs}
alwaysExpanded={alwaysExpanded}
/>
);
};
const hasMentionItems = (mentionItems?.length ?? 0) > 0;
const hasAnyRowAbove = isImageRowVisible || !!mediaReferenceRow;
// Filtered mention items for autocomplete
const filteredMentionItems = useMemo(() => {
@@ -757,28 +334,80 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
});
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
// Shared reference-image row. Inline it attaches to the top of the box
// (square bottom); the fullscreen modal passes a className to round the
// bottom corners since it sits in-flow above the controls there.
const renderImagePromptRow = (className?: string) =>
supportsImagePrompts ? (
<ImagePromptRow
maxImagePromptCount={maxImagePromptCount}
referenceImages={referenceImages}
setReferenceImages={onReferenceImagesChange}
onPickFromLibrary={onPickFromLibrary}
onClearAll={onClearAllRefs}
isVideo={isVideo}
isReferenceMode={isReferenceMode}
endFrameImage={endFrameImage}
setEndFrameImage={onEndFrameImageChange}
showEndFrameSection={showEndFrameSection}
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
className={className}
/>
) : null;
return (
<div ref={ref} className="prompt-box-root">
<div className="relative flex flex-col">
{deck.fileInputs}
{isImageRowVisible && renderImagePromptRow()}
{/* When the media row is the topmost band (no image row above it —
e.g. the audio page), clip it to rounded top corners; mid-stack
(video's start-frame row above) it stays square. */}
{mediaReferenceRow && (
<div className={twMerge(!isImageRowVisible && "sm:rounded-t-2xl")}>
{mediaReferenceRow}
</div>
)}
<div
className={twMerge(
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
hasAnyRowAbove && "rounded-t-none border-t-0",
isFocused && "ring-1 ring-primary",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={dropAcceptsImages}
acceptsVideos={dropAcceptsVideos}
acceptsAudio={dropAcceptsAudio}
keyframeMode={isKeyframeMode}
/>
<div className="flex gap-3">
{renderReferenceWidget()}
{referenceSlots}
{supportsImagePrompts && !isVideo && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<button
type="button"
className={twMerge(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-transparent p-0 transition-all hover:text-primary-500",
isImageRowVisible && "text-primary",
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="opacity-80 transition-all hover:opacity-100"
>
<path
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</button>
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
{hasMentionItems && mentionItems ? (
@@ -932,24 +561,22 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
)}
{/* Toolbar */}
<div className="mt-3.5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{modelSelector}
{leftToolbar}
</div>
<div className="flex items-center gap-1.5 sm:shrink-0">
<div className="flex items-center gap-2 sm:shrink-0">
{rightToolbar}
<PromptClearAllButton
onClick={handleClearAll}
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
<GenerateIconButton
<GenerateButton
className="flex flex-1 sm:flex-none items-center justify-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSubmit}
disabled={disabled ?? (!prompt.trim() || isSubmitting)}
loading={isSubmitting}
credits={credits}
/>
>
{submitLabel}
</GenerateButton>
</div>
</div>
@@ -984,21 +611,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
{leftToolbar}
</>
}
imagePromptRow={
renderReferenceWidget(true) || referenceSlots ? (
<div className="flex flex-wrap items-center gap-3">
{renderReferenceWidget(true)}
{referenceSlots}
</div>
) : undefined
}
clearAllButton={
<PromptClearAllButton
onClick={handleClearAll}
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
}
imagePromptRow={renderImagePromptRow("rounded-2xl sm:rounded-b-2xl")}
>
{hasMentionItems && mentionItems ? (
<MentionTextarea
@@ -1,289 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faImages, faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "../toast/toast";
// ── Types ───────────────────────────────────────────────────────────────
type DropKind = "image" | "video" | "audio";
/** "accept" / "reject" reflect whether the hovered payload contains at
* least one file the current model can take as a reference. */
export type DropDragState = "idle" | "accept" | "reject";
export interface DroppedFiles {
images: File[];
videos: File[];
audios: File[];
}
interface UsePromptBoxDropOptions {
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
onDropFiles: (files: DroppedFiles) => void;
}
interface PromptBoxDropOverlayProps {
dragState: DropDragState;
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
/** Video keyframe mode: images fill the start/end frame slots. */
keyframeMode?: boolean;
}
const kindOfMime = (mime: string): DropKind | null =>
mime.startsWith("image/")
? "image"
: mime.startsWith("video/")
? "video"
: mime.startsWith("audio/")
? "audio"
: null;
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const listKinds = (labels: string[]) =>
labels.length <= 1
? (labels[0] ?? "")
: `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
/**
* Makes the prompt box a drop target for reference media, and catches
* clipboard pastes of files anywhere on the page (focus-independent). Files
* are routed by MIME type; kinds the current model doesn't take are
* rejected with a toast instead of silently vanishing.
*/
export function usePromptBoxDrop({
acceptsImages,
acceptsVideos,
acceptsAudio,
onDropFiles,
}: UsePromptBoxDropOptions) {
const [dragState, setDragState] = useState<DropDragState>("idle");
const enabled = acceptsImages || acceptsVideos || acceptsAudio;
const acceptsKind = useCallback(
(kind: DropKind | null) =>
kind === "image"
? acceptsImages
: kind === "video"
? acceptsVideos
: kind === "audio"
? acceptsAudio
: false,
[acceptsImages, acceptsVideos, acceptsAudio],
);
// While the box is on screen, a stray drop outside it must not navigate
// the browser to the file (which would blow away the user's session).
useEffect(() => {
if (!enabled) return;
const preventNavigation = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) e.preventDefault();
};
// Drops that land outside the box still need the overlay dismissed.
const clearDragState = () => setDragState("idle");
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
window.addEventListener("drop", clearDragState);
window.addEventListener("dragend", clearDragState);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
window.removeEventListener("drop", clearDragState);
window.removeEventListener("dragend", clearDragState);
};
}, [enabled]);
// MIME types are unreliable mid-drag (browsers may report empty strings),
// so unknown types count as acceptable until the drop resolves them.
const inspectDrag = useCallback(
(dt: DataTransfer): DropDragState => {
const fileItems = Array.from(dt.items).filter((i) => i.kind === "file");
if (fileItems.length === 0) return "accept";
const ok = fileItems.some(
(i) => i.type === "" || acceptsKind(kindOfMime(i.type)),
);
return ok ? "accept" : "reject";
},
[acceptsKind],
);
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragState(inspectDrag(e.dataTransfer));
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
if (dragState === "idle") return;
// Child elements fire enter/leave pairs constantly; only reset when the
// pointer actually exits the drop target's bounds.
const rect = e.currentTarget.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX >= rect.right ||
e.clientY < rect.top ||
e.clientY >= rect.bottom
) {
setDragState("idle");
}
};
const routeFiles = (files: File[]) => {
const accepted: DroppedFiles = { images: [], videos: [], audios: [] };
const rejectedKinds = new Set<DropKind>();
let unknownCount = 0;
for (const file of files) {
const kind = kindOfMime(file.type);
if (kind === null) {
unknownCount++;
} else if (!acceptsKind(kind)) {
rejectedKinds.add(kind);
} else if (kind === "image") {
accepted.images.push(file);
} else if (kind === "video") {
accepted.videos.push(file);
} else {
accepted.audios.push(file);
}
}
const anyAccepted =
accepted.images.length > 0 ||
accepted.videos.length > 0 ||
accepted.audios.length > 0;
if (anyAccepted) onDropFiles(accepted);
// Mode-neutral wording: a kind can be off because the model lacks it or
// because the current input mode (e.g. keyframes) doesn't show it.
if (rejectedKinds.size > 0) {
toast.error(
`${capitalize(listKinds([...rejectedKinds]))} references aren't available here`,
);
} else if (unknownCount > 0 && !anyAccepted) {
toast.error(
`Only ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files can be added here`,
);
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
setDragState("idle");
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) routeFiles(files);
};
// Paste-to-add: a copied image (or media file) pasted anywhere on the
// page lands in the deck, whether or not the textarea is focused. The
// listener re-reads the freshest router through a ref so it can stay
// subscribed once.
const routeFilesRef = useRef(routeFiles);
useEffect(() => {
routeFilesRef.current = routeFiles;
});
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
// Plain text pastes carry no files — leave them entirely alone.
if (files.length === 0) return;
// Guard against double-adding if more than one box ever mounts.
const marked = e as ClipboardEvent & { promptBoxHandled?: boolean };
if (marked.promptBoxHandled) return;
marked.promptBoxHandled = true;
// No preventDefault: any text alongside the file still pastes into
// whichever field is focused.
routeFilesRef.current(files);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
return {
dragState,
dropZoneProps: {
onDragEnter: handleDragOver,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
/**
* Full-bleed dashed overlay shown while files hover the prompt box, listing
* the reference kinds the current model takes (red when none match).
*/
export function PromptBoxDropOverlay({
dragState,
acceptsImages,
acceptsVideos,
acceptsAudio,
keyframeMode,
}: PromptBoxDropOverlayProps) {
if (dragState === "idle") return null;
const kinds = [
...(acceptsImages
? [{ icon: faImages, label: keyframeMode ? "Frames" : "Images" }]
: []),
...(acceptsVideos ? [{ icon: faVideo, label: "Video" }] : []),
...(acceptsAudio ? [{ icon: faMusic, label: "Audio" }] : []),
];
const rejected = dragState === "reject";
return (
<div
className={twMerge(
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
rejected ? "border-red-400/80" : "border-primary",
)}
>
<div className="flex items-center gap-2">
{kinds.map((kind, i) => (
<div
key={kind.label}
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
style={{ animationDelay: `${i * 50}ms` }}
>
<FontAwesomeIcon icon={kind.icon} className="text-sm" />
</div>
))}
</div>
<div className="text-sm font-semibold text-base-fg">
{rejected
? "That file type isn't supported"
: keyframeMode && !acceptsVideos && !acceptsAudio
? "Drop to set your frames"
: "Drop to add references"}
</div>
<div className="-mt-1 text-xs text-base-fg/60">
{rejected
? `Accepts ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files`
: kinds.map((kind) => kind.label).join(" · ")}
</div>
</div>
);
}
function acceptedKindLabels(
acceptsImages: boolean,
acceptsVideos: boolean,
acceptsAudio: boolean,
): string[] {
return [
...(acceptsImages ? ["image"] : []),
...(acceptsVideos ? ["video"] : []),
...(acceptsAudio ? ["audio"] : []),
];
}
@@ -1,4 +1,10 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import {
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -60,8 +66,6 @@ interface PromptFullscreenModalProps {
footerControls?: ReactNode;
/** Reference-image row, always shown in focus mode. */
imagePromptRow?: ReactNode;
/** Clear-all control rendered on the footer's right side, left of Done. */
clearAllButton?: ReactNode;
}
export const PromptFullscreenModal = ({
@@ -72,7 +76,6 @@ export const PromptFullscreenModal = ({
maxPromptLength,
footerControls,
imagePromptRow,
clearAllButton,
}: PromptFullscreenModalProps) => {
const showCounter =
promptLength !== undefined && maxPromptLength !== undefined;
@@ -83,10 +86,7 @@ export const PromptFullscreenModal = ({
// Focus the editor and drop the caret at the end once the overlay opens.
useEffect(() => {
if (!isOpen) return;
const id = window.setTimeout(
() => focusEditorAtEnd(contentRef.current),
60,
);
const id = window.setTimeout(() => focusEditorAtEnd(contentRef.current), 60);
return () => window.clearTimeout(id);
}, [isOpen]);
@@ -123,18 +123,14 @@ export const PromptFullscreenModal = ({
overLimit ? "text-red-500" : "text-base-fg/40"
}`}
>
{promptLength} /{" "}
{isFinite(maxPromptLength) ? maxPromptLength : "∞"}
{promptLength} / {isFinite(maxPromptLength) ? maxPromptLength : "∞"}
</span>
</div>
)}
{imagePromptRow && <div className="shrink-0">{imagePromptRow}</div>}
<div className="flex shrink-0 items-center justify-between gap-2 mt-1">
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2">{footerControls}</div>
<div className="flex items-center gap-2">
{clearAllButton}
<Button onClick={onClose}>Done</Button>
</div>
<Button onClick={onClose}>Done</Button>
</div>
</div>
</Modal>
@@ -10,6 +10,7 @@ interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
@@ -19,6 +20,8 @@ export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
@@ -5,10 +5,7 @@ export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
export type { StoredCharacter } from "./characters-store";
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
export {
getAudioDurationFromUrl,
getVideoDurationFromUrl,
} from "./upload-media";
export { getVideoDurationFromUrl } from "./upload-media";
export {
MobilePromptForm,
MobileSelectField,
@@ -83,16 +83,6 @@ export const getVideoDurationFromUrl = (url: string): Promise<number> =>
video.src = url;
});
// Resolves 0 when metadata can't be loaded.
export const getAudioDurationFromUrl = (url: string): Promise<number> =>
new Promise((resolve) => {
const audio = document.createElement("audio");
audio.preload = "metadata";
audio.onloadedmetadata = () => resolve(Math.round(audio.duration));
audio.onerror = () => resolve(0);
audio.src = url;
});
export const getVideoDuration = (file: File): Promise<number> => {
const url = URL.createObjectURL(file);
return getVideoDurationFromUrl(url).finally(() => URL.revokeObjectURL(url));
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { USER_FEATURE_FLAGS } from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
@@ -17,20 +17,41 @@ interface SettingsModalProps {
onClose: () => void;
}
const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
{ id: "general", label: "General", icon: faCog },
{ id: "account", label: "Account", icon: faUser },
{ id: "apiKeys", label: "API Keys", icon: faKey },
];
const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = {
id: "apiKeys",
label: "API Keys",
icon: faKey,
};
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { user } = useSession();
const [tab, setTab] = useState<Tab>("general");
const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.API_KEY,
);
const tabs = useMemo(
() => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS),
[hasApiKeyFlag],
);
useEffect(() => {
if (isOpen) setTab("general");
}, [isOpen]);
const activeLabel = TABS.find((t) => t.id === tab)?.label ?? "";
// If the only-conditional tab disappears (e.g. flag revoked while open),
// fall back to a visible tab.
useEffect(() => {
if (!tabs.some((t) => t.id === tab)) setTab("general");
}, [tabs, tab]);
const activeLabel = tabs.find((t) => t.id === tab)?.label ?? "";
return (
<Modal
@@ -47,7 +68,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
{TABS.map((t) => (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
@@ -84,8 +105,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
function GeneralPanel() {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const lightboxSound = useLightboxSoundStore((s) => s.soundEnabled);
const setLightboxSound = useLightboxSoundStore((s) => s.setSoundEnabled);
return (
<div className="space-y-4 text-base-fg">
@@ -104,20 +123,6 @@ function GeneralPanel() {
offClassName="bg-white/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="text-xs opacity-70">
When on (default), videos in the media viewer start unmuted so you
don't have to click unmute every time. When off, they start muted.
</p>
</div>
<Switch
enabled={lightboxSound}
setEnabled={setLightboxSound}
offClassName="bg-white/20"
/>
</div>
</div>
);
}
@@ -18,7 +18,6 @@ import {
faDownload,
faGift,
faPencil,
faPhotoFilm,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -79,12 +78,6 @@ const STUDIO_ITEMS_STATIC: NavItem[] = [
icon: faObjectGroup,
badge: "BETA",
},
{
label: "Frame Extract",
href: "/frame-extractor",
icon: faPhotoFilm,
badge: "NEW",
},
];
// Rewrite the "Edit 3D" item's href to point at the user's last visited
@@ -172,11 +165,7 @@ function NavMenuItem({
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
}`}
>
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
{item.badge}
</span>
)}
@@ -1,17 +1,13 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBorderAll,
faChevronRight,
faFolder,
faFolderOpen,
faPlus,
faStar,
} from "@fortawesome/pro-solid-svg-icons";
import { compareFolders } from "@storyteller/ui-gallery-modal";
import { EASE_EMPHASIS } from "../../lib/motion";
import {
SidebarGroup,
SidebarGroupContent,
@@ -21,7 +17,6 @@ import {
SidebarMenuItem,
} from "../ui/sidebar";
import { useLibraryFoldersStore } from "../../pages/library/library-folders-store";
import { LibraryTagsNav } from "./library-tags-nav";
/**
* The "Library" sidebar section: Unsorted + Folders entries, plus the user's
@@ -39,24 +34,16 @@ export function LibraryFoldersNav({
const onFolders =
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
const onFolderless = pathname === "/library/folderless";
const onTags =
pathname === "/library/tags" || pathname.startsWith("/library/tag_");
const onUnsorted =
!onFolders &&
!onFolderless &&
!onTags &&
(pathname === "/library" || pathname.startsWith("/library/"));
const inLibraryArea = onUnsorted || onFolders || onFolderless || onTags;
const inLibraryArea = onUnsorted || onFolders;
const folders = useLibraryFoldersStore((s) => s.folders);
const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId);
const openNewFolderModal = useLibraryFoldersStore((s) => s.openNewFolderModal);
const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu);
// Folders are the primary organization tool — default expanded.
const [expanded, setExpanded] = useState(true);
const rootFolders = useMemo(
() => folders.filter((f) => !f.parentId).sort(compareFolders),
[folders],
@@ -105,18 +92,6 @@ export function LibraryFoldersNav({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={onFolderless}
tooltip="Unfoldered"
>
<Link to="/library/folderless" onClick={onNavClick}>
<FontAwesomeIcon icon={faFolderOpen} />
<span>Unfoldered</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
@@ -128,74 +103,46 @@ export function LibraryFoldersNav({
<span>Folders</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && rootFolders.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse folders" : "Expand folders"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
<AnimatePresence initial={false}>
{inLibraryArea && expanded && rootFolders.length > 0 && (
<motion.li
key="folder-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
{inLibraryArea &&
rootFolders.map((folder) => (
<SidebarMenuItem
key={folder.id}
className="group-data-[collapsible=icon]:hidden"
>
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{rootFolders.map((folder) => (
<SidebarMenuItem key={folder.id}>
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode
? { color: folder.colorCode }
: undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</ul>
</motion.li>
)}
</AnimatePresence>
<LibraryTagsNav pathname={pathname} onNavClick={onNavClick} />
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode ? { color: folder.colorCode } : undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -1,163 +0,0 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faEllipsis,
faTag,
faTags,
} from "@fortawesome/pro-solid-svg-icons";
import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import {
compareTagsByUseCount,
useLibraryTagsStore,
} from "../../pages/library/library-tags-store";
import { EASE_EMPHASIS } from "../../lib/motion";
const MAX_SIDEBAR_TAGS = 5;
/**
* The "Tags" rows of the Assets sidebar group, rendered below the folders
* list: a Tags entry plus the user's most-used tags (collapsed by default)
* while in the library area. Hidden entirely until the user has tags — the
* tag editor in the media details panel is where tags get created.
* Right-click opens the library page's tag context menu (same contract as
* the folder rows). Renders bare menu items; the parent supplies the
* `SidebarMenu` list.
*/
export function LibraryTagsNav({
pathname,
onNavClick,
}: {
pathname: string;
onNavClick: () => void;
}) {
const navigate = useNavigate();
const tags = useLibraryTagsStore((s) => s.tags);
const tagsLoaded = useLibraryTagsStore((s) => s.tagsLoaded);
const setContextMenu = useLibraryTagsStore((s) => s.setContextMenu);
// Folders are the primary organization tool — tags start collapsed.
const [expanded, setExpanded] = useState(false);
const inLibraryArea =
pathname === "/library" || pathname.startsWith("/library/");
const topTags = useMemo(
() => [...tags].sort(compareTagsByUseCount).slice(0, MAX_SIDEBAR_TAGS),
[tags],
);
if (!tagsLoaded || tags.length === 0) return null;
return (
<>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/library/tags"}
tooltip="Tags"
>
<Link to="/library/tags" onClick={onNavClick}>
<FontAwesomeIcon icon={faTags} />
<span>Tags</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && topTags.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse tags" : "Expand tags"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
<AnimatePresence initial={false}>
{inLibraryArea && expanded && topTags.length > 0 && (
<motion.li
key="tag-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
>
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{topTags.map((tag) => (
<SidebarMenuItem key={tag.token}>
<SidebarMenuButton
isActive={pathname === `/library/${tag.token}`}
tooltip={tag.value}
onClick={() => {
navigate(`/library/${tag.token}`);
onNavClick();
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
tagToken: tag.token,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5"
>
<FontAwesomeIcon
icon={faTag}
className="text-violet-400"
/>
<span className="truncate">{tag.value}</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
{tag.useCount}
</span>
</SidebarMenuButton>
{/* Hover-revealed rename/delete menu (swaps with the count). */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({
tagToken: tag.token,
x: rect.left,
y: rect.bottom + 4,
});
}}
aria-label={`Options for tag "${tag.value}"`}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<FontAwesomeIcon icon={faEllipsis} className="text-xs" />
</button>
</SidebarMenuItem>
))}
{tags.length > MAX_SIDEBAR_TAGS && (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="See all tags">
<Link
to="/library/tags"
onClick={onNavClick}
className="pl-5 text-sidebar-foreground/60"
>
<FontAwesomeIcon icon={faEllipsis} />
<span>See all tags</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40">
{tags.length}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</ul>
</motion.li>
)}
</AnimatePresence>
</>
);
}
@@ -1,54 +0,0 @@
import { useEffect, useMemo } from "react";
import type { UserInfo } from "@storyteller/api";
import { TagsSection as SharedTagsSection } from "@storyteller/ui-lightbox-modal";
import { useSession } from "../../lib/session";
import {
compareTagsByUseCount,
useLibraryTagsStore,
} from "../../pages/library/library-tags-store";
interface TagsSectionProps {
mediaToken?: string | null;
creator?: UserInfo | null;
}
/**
* Store-wired wrapper around the shared tags editor (from
* `@storyteller/ui-lightbox-modal`, also used by the desktop gallery):
* session comes from the webapp session store, autocomplete suggestions from
* the preloaded tags store, and saves feed fresh use counts back into it so
* the sidebar and tag browser stay current.
*/
export function TagsSection({ mediaToken, creator }: TagsSectionProps) {
const { user, loggedIn } = useSession();
const storeTags = useLibraryTagsStore((s) => s.tags);
const loadTags = useLibraryTagsStore((s) => s.loadTags);
const upsertTags = useLibraryTagsStore((s) => s.upsertTags);
const currentUsername = loggedIn ? (user?.username ?? null) : null;
const isOwner =
!!currentUsername && creator?.username === currentUsername;
// Preload the user's tag list once for autocomplete.
useEffect(() => {
if (isOwner && !useLibraryTagsStore.getState().tagsLoaded) loadTags();
}, [isOwner, loadTags]);
const suggestions = useMemo(
() =>
[...storeTags]
.sort(compareTagsByUseCount)
.map((t) => ({ value: t.value, useCount: t.useCount })),
[storeTags],
);
return (
<SharedTagsSection
mediaToken={mediaToken}
creator={creator}
currentUsername={currentUsername}
suggestions={suggestions}
onSaved={upsertTags}
/>
);
}
@@ -7,22 +7,12 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
"/": [{ label: "Home" }],
"/create-image": [{ label: "Create", href: "/" }, { label: "Image" }],
"/create-video": [{ label: "Create", href: "/" }, { label: "Video" }],
"/create-audio": [{ label: "Create", href: "/" }, { label: "Audio" }],
"/create-object": [{ label: "Create", href: "/" }, { label: "3D Object" }],
"/create-world": [{ label: "Create", href: "/" }, { label: "3D World" }],
"/background-change": [
{ label: "Studio", href: "/" },
{ label: "Create", href: "/" },
{ label: "Background Change" },
],
"/edit-image": [{ label: "Studio", href: "/" }, { label: "Edit Image" }],
"/edit-3d": [{ label: "Studio", href: "/" }, { label: "Edit 3D" }],
"/video-editor": [{ label: "Studio", href: "/" }, { label: "Edit Video" }],
"/moodboard": [{ label: "Studio", href: "/" }, { label: "Moodboard" }],
"/frame-extractor": [
{ label: "Studio", href: "/" },
{ label: "Frame Extractor" },
],
"/referrals": [{ label: "Referrals" }],
"/media": [{ label: "Library", href: "/library" }, { label: "Media" }],
"/pricing": [{ label: "Pricing" }],
"/support": [{ label: "Support" }],
@@ -42,26 +32,15 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
};
function resolveCrumbs(pathname: string): Crumb[] {
// Library has sub-tabs (Unsorted / Folders / Tags) that live under /library/*
// rather than being separate top-level pages.
// Library has sub-tabs (Unsorted / Folders) that live under /library/* rather
// than being separate top-level pages.
if (pathname === "/library" || pathname.startsWith("/library/")) {
const onFolders =
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
const onFolderless = pathname === "/library/folderless";
const onTags =
pathname === "/library/tags" || pathname.startsWith("/library/tag_");
return [
{ label: "Library", href: "/library" },
{
label: onFolders
? "Folders"
: onFolderless
? "Unfoldered"
: onTags
? "Tags"
: "All Assets",
},
{ label: onFolders ? "Folders" : "All Assets" },
];
}
if (ROUTE_CRUMBS[pathname]) return ROUTE_CRUMBS[pathname];
@@ -905,15 +905,6 @@ export const TaskQueue = () => {
);
prevFailedIdsRef.current = newFailedIdSet;
if (newlyFailedJobs.length > 0) {
// A failure may have triggered a server-side refund — nudge the
// credits chip now, and again once the refund has settled.
window.dispatchEvent(new Event("credits-change"));
setTimeout(() => {
window.dispatchEvent(new Event("credits-change"));
}, 2500);
}
for (const job of newlyFailedJobs) {
const parts = formatTitleParts(job);
const failureCategory =
@@ -1,10 +1,6 @@
import { Link, useLocation } from "react-router-dom";
import { SidebarTrigger, useSidebar } from "../ui/sidebar";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "../generation-gallery/GalleryViewToggle";
import { GalleryViewToggle } from "../generation-gallery/GalleryViewToggle";
import { Breadcrumbs } from "./breadcrumbs";
import { TopBarActions } from "./TopBarActions";
@@ -18,10 +14,6 @@ export function TopBar() {
// On mobile the toggle lives next to the Generate/History tabs (see
// CreateMediaPageShell), so keep it out of the top bar there.
const showViewToggle = !isMobile && GALLERY_VIEW_ROUTES.has(pathname);
// Playing/still video previews only matter where the feed has videos.
const showAutoplayToggle = showViewToggle && pathname === "/create-video";
// Multi-select + batch download on the create feeds (image + video).
const showSelectToggle = showViewToggle;
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
@@ -49,8 +41,6 @@ export function TopBar() {
{/* Right: gallery layout toggle (create pages) + credits / upgrade / task queue / avatar */}
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
{showViewToggle && <GalleryViewToggle />}
<TopBarActions />
</div>
@@ -1,97 +0,0 @@
import JSZip from "jszip";
import { addCorsParam } from "@storyteller/common";
import { extensionForUrl } from "./download-media";
// How many media fetches run at once while filling the zip.
const FETCH_CONCURRENCY = 3;
export interface ZipDownloadItem {
id: string;
url: string;
mediaClass?: string | null;
}
export interface ZipDownloadResult {
succeeded: number;
failed: number;
}
// Fetches each item (same CORS/dl params as downloadMediaFile) and saves them
// all as a single zip. A single-item selection skips the zip and saves the
// file directly. Items that fail to fetch are skipped — the zip is still
// produced as long as at least one item succeeded. Callers surface the counts.
export async function downloadItemsAsZip(
items: ZipDownloadItem[],
opts?: { onProgress?: (done: number, total: number) => void },
): Promise<ZipDownloadResult> {
if (items.length === 1) {
const item = items[0];
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
saveBlob(blob, `artcraft-${item.id}.${ext}`);
opts?.onProgress?.(1, 1);
return { succeeded: 1, failed: 0 };
} catch {
opts?.onProgress?.(1, 1);
return { succeeded: 0, failed: 1 };
}
}
const zip = new JSZip();
let succeeded = 0;
let failed = 0;
let done = 0;
let next = 0;
const worker = async () => {
while (next < items.length) {
const item = items[next];
next += 1;
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
zip.file(`artcraft-${item.id}.${ext}`, blob);
succeeded += 1;
} catch {
failed += 1;
}
done += 1;
opts?.onProgress?.(done, items.length);
}
};
await Promise.all(
Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker),
);
if (succeeded > 0) {
// Media files are already compressed — STORE skips pointless deflate work.
const blob = await zip.generateAsync({ type: "blob", compression: "STORE" });
saveBlob(blob, `artcraft-media-${Date.now()}.zip`);
}
return { succeeded, failed };
}
async function fetchItemBlob(item: ZipDownloadItem): Promise<Blob> {
const corsUrl = addCorsParam(item.url) || item.url;
const response = await fetch(`${corsUrl}&dl=1`, { credentials: "omit" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob();
}
function saveBlob(blob: Blob, filename: string) {
const blobUrl = window.URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
window.URL.revokeObjectURL(blobUrl);
}
}
@@ -5,14 +5,10 @@ const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
audio: "mp3",
// "dimensional" is the deprecated pre-split 3D class (rows persist until
// the backfill lands); mesh/splat are its replacements.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
export function extensionForUrl(url: string, mediaClass?: string | null): string {
function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const pathname = new URL(url, window.location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
@@ -1,26 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Audio behavior for lightbox videos: whether they open with sound (on by
// default, toggled in the settings modal's General tab) and the last volume
// the user picked, so each video doesn't reset to 100%.
interface LightboxSoundStore {
soundEnabled: boolean;
setSoundEnabled: (enabled: boolean) => void;
// 0..1, mirrors HTMLMediaElement.volume.
volume: number;
setVolume: (volume: number) => void;
}
export const useLightboxSoundStore = create<LightboxSoundStore>()(
persist(
(set) => ({
soundEnabled: true,
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
volume: 1,
setVolume: (volume) => set({ volume }),
}),
{ name: "artcraft-lightbox-sound" },
),
);
@@ -1,137 +0,0 @@
import type { NavigateFunction } from "react-router-dom";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { toast } from "../components/toast/toast";
import type { RefImage, RefVideo } from "../components/prompt-box/types";
import { useCreateImageStore } from "../pages/create-image/create-image-store";
import { useCreateVideoStore } from "../pages/create-video/create-video-store";
export type PromptDestination = "image" | "video";
const DESTINATION_ROUTES: Record<PromptDestination, string> = {
image: "/create-image",
video: "/create-video",
};
export function isImagePromptable(item: GalleryItem): boolean {
return item.mediaClass === "image" && !!(item.thumbnail || item.fullImage);
}
// Videos can only be prompted from on the video page (as reference videos,
// model permitting).
export function isVideoPromptable(item: GalleryItem): boolean {
return item.mediaClass === "video" && !!item.fullImage;
}
// Parks library media for a create page's reference deck and navigates
// there. The reference caps are per-model and only known on the receiving
// page, so the media travels via the store's pending slots and the page
// merges it (dedupe + real caps) once its model list is loaded.
export function sendToPrompt(
items: GalleryItem[],
destination: PromptDestination,
navigate: NavigateFunction,
): void {
const images = items.filter(isImagePromptable);
const videos = destination === "video" ? items.filter(isVideoPromptable) : [];
if (images.length === 0 && videos.length === 0) {
toast.error("Select at least one image to use as a reference");
return;
}
if (destination === "image") {
useCreateImageStore
.getState()
.setPendingRefImages(images.map(galleryItemToRefImage));
if (items.some(isVideoPromptable)) {
toast.success(
`Sent ${images.length} ${images.length === 1 ? "image" : "images"} — videos can't be image references`,
);
}
} else {
const store = useCreateVideoStore.getState();
if (images.length > 0) {
store.setPendingRefImages(images.map(galleryItemToRefImage));
}
if (videos.length > 0) {
store.setPendingRefVideos(videos.map(galleryItemToRefVideo));
}
}
navigate(DESTINATION_ROUTES[destination]);
}
export function galleryItemToRefImage(item: GalleryItem): RefImage {
return {
id: crypto.randomUUID(),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
};
}
function galleryItemToRefVideo(item: GalleryItem): RefVideo {
return {
id: crypto.randomUUID(),
url: item.fullImage || "",
file: new File([], "library-video"),
mediaToken: item.id,
// 0 = unknown; the video page probes the file before adding it.
duration: item.durationMillis ? item.durationMillis / 1000 : 0,
};
}
// ── Merge (used by the receiving create pages) ─────────────────────────────
export interface MergeRefImagesResult {
next: RefImage[];
added: number;
// Incoming refs whose media token was already attached.
duplicates: number;
// Fresh refs that didn't fit under the model's cap.
overflow: number;
}
// Merges incoming reference images into an existing deck: skips refs whose
// media token is already attached, then clamps to the model's cap.
export function mergeRefImages(
existing: RefImage[],
incoming: RefImage[],
maxRefs: number,
): MergeRefImagesResult {
const existingTokens = new Set(
existing.map((ref) => ref.mediaToken).filter(Boolean),
);
const fresh = incoming.filter(
(ref) => !ref.mediaToken || !existingTokens.has(ref.mediaToken),
);
const slots = Math.max(0, maxRefs - existing.length);
const added = fresh.slice(0, slots);
return {
next: [...existing, ...added],
added: added.length,
duplicates: incoming.length - fresh.length,
overflow: fresh.length - added.length,
};
}
// Feedback for a merge. Silent when everything landed or the only skips were
// images already attached (either way, everything selected is now in the
// deck); otherwise say what was left out and why.
export function toastMergeRefImagesOutcome(
result: MergeRefImagesResult,
maxRefs: number,
): void {
if (result.overflow === 0) return;
if (result.added > 0) {
const sent = result.added + result.overflow;
toast.success(
`Added ${result.added} of ${sent} images — this model takes up to ${maxRefs} references`,
);
} else if (maxRefs === 0) {
toast.error("The selected model doesn't take image references");
} else {
toast.error(
`Couldn't add more images — this model's reference limit (${maxRefs}) is reached`,
);
}
}
+5 -5
View File
@@ -20,11 +20,11 @@ if (import.meta.env.DEV) {
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
window.location.origin,
);
// NB: This overrides the origin above and points all API traffic at the
// local storyteller-web on http://localhost:12345. Dev REQUIRES the local
// backend (see _docs/dev_setup_local_stack.md) — without it the webapp is
// broken in dev, not silently on production.
StorytellerApiHostStore.getInstance().setDevelopment();
// NB: This is for Brandon to test with storyteller-web locally:
// (disabled — it overrides the origin above and points at localhost:12345,
// which breaks dev unless a local backend is running. Re-enable only when
// testing against a local storyteller-web.)
// StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -31,8 +31,6 @@ import {
SettingsDrawer,
DrawerOptionList,
DrawerSection,
getAudioDurationFromUrl,
type RefAudio,
type RefImage,
} from "../../components/prompt-box";
import {
@@ -121,17 +119,22 @@ export default function CreateAudio() {
const ui = useCreateAudioStore((s) => s.ui);
const setUi = useCreateAudioStore((s) => s.setUi);
const selectedModel = useMemo((): OmniGenAudioModelDetails | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
const selectedModel = useMemo(
(): OmniGenAudioModelDetails | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
);
}
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
},
[apiModels, ui.selectedModelId],
);
const prompt = ui.prompt;
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
@@ -187,10 +190,6 @@ export default function CreateAudio() {
const setReferenceImages = useCreateAudioStore((s) => s.setReferenceImages);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
const [isAudioPickerOpen, setIsAudioPickerOpen] = useState(false);
const [audioPickerSelectedIds, setAudioPickerSelectedIds] = useState<
string[]
>([]);
const [isBeatDrawerOpen, setIsBeatDrawerOpen] = useState(false);
const [isTuningDrawerOpen, setIsTuningDrawerOpen] = useState(false);
@@ -198,10 +197,6 @@ export default function CreateAudio() {
if (isImagePickerOpen) setPickerSelectedIds([]);
}, [isImagePickerOpen]);
useEffect(() => {
if (isAudioPickerOpen) setAudioPickerSelectedIds([]);
}, [isAudioPickerOpen]);
const handlePickerSelect = useCallback(
(id: string) => {
setPickerSelectedIds((prev) => {
@@ -282,9 +277,7 @@ export default function CreateAudio() {
(audios: typeof referenceAudios) => {
if (audios.length > 0 && referenceImages.length > 0) {
setReferenceImages([]);
toast.error(
"Removed image reference — it can't be combined with audio",
);
toast.error("Removed image reference — it can't be combined with audio");
}
setReferenceAudios(audios);
},
@@ -295,82 +288,22 @@ export default function CreateAudio() {
(images: RefImage[]) => {
if (images.length > 0 && referenceAudios.length > 0) {
setReferenceAudios([]);
toast.error(
"Removed audio reference — it can't be combined with an image",
);
toast.error("Removed audio reference — it can't be combined with an image");
}
setReferenceImages(images);
},
[referenceAudios, setReferenceAudios, setReferenceImages],
);
const audioPickerMax = Math.max(1, maxAudioRefs - referenceAudios.length);
const handleAudioPickerSelect = useCallback(
(id: string) => {
setAudioPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= audioPickerMax) {
return audioPickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[audioPickerMax],
);
const handleLibraryAudioSelect = useCallback(
async (items: GalleryItem[]) => {
setIsAudioPickerOpen(false);
const availableSlots = Math.max(0, maxAudioRefs - referenceAudios.length);
const picked = items.slice(0, availableSlots);
const added: RefAudio[] = [];
let total = referenceAudios.reduce((sum, a) => sum + a.duration, 0);
for (const item of picked) {
const url = item.fullImage;
if (!url) continue;
const duration =
item.durationMillis != null
? Math.round(item.durationMillis / 1000)
: await getAudioDurationFromUrl(url);
if (duration <= 0) {
toast.error("Could not read audio file");
continue;
}
if (total + duration > AUDIO_REF_MAX_DURATION_SECONDS) {
toast.error(
`Total audio duration cannot exceed ${AUDIO_REF_MAX_DURATION_SECONDS}s`,
);
continue;
}
total += duration;
added.push({
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-audio"),
mediaToken: item.id,
duration,
});
}
if (added.length > 0) {
handleReferenceAudiosChange([...referenceAudios, ...added]);
}
},
[referenceAudios, maxAudioRefs, handleReferenceAudiosChange],
);
const handleLibraryImageSelect = useCallback(
(items: GalleryItem[]) => {
const newImages: RefImage[] = items
.slice(0, maxImageRefs)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
const newImages: RefImage[] = items.slice(0, maxImageRefs).map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
handleReferenceImagesChange(newImages);
setIsImagePickerOpen(false);
},
@@ -548,7 +481,6 @@ export default function CreateAudio() {
</>
);
// Mobile-only band; on desktop the audio refs live in the reference deck.
const audioReferenceRow = audioRefsSupported ? (
<MediaReferenceRow
videoSupported={false}
@@ -561,7 +493,6 @@ export default function CreateAudio() {
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={maxAudioRefs}
maxAudioRefDuration={AUDIO_REF_MAX_DURATION_SECONDS}
onPickAudioFromLibrary={() => setIsAudioPickerOpen(true)}
/>
) : undefined;
@@ -789,7 +720,7 @@ export default function CreateAudio() {
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -808,14 +739,7 @@ export default function CreateAudio() {
referenceImages={referenceImages}
onReferenceImagesChange={handleReferenceImagesChange}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
audioRefsSupported={audioRefsSupported}
referenceAudios={referenceAudios}
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={maxAudioRefs}
maxAudioRefDuration={AUDIO_REF_MAX_DURATION_SECONDS}
onPickAudioFromLibrary={() => setIsAudioPickerOpen(true)}
onClearAllExtras={() => setStylePrompt("")}
hasClearableExtras={ui.stylePrompt.length > 0}
mediaReferenceRow={audioReferenceRow}
secondaryPromptRow={
styleSupported ? (
<StylePromptRow
@@ -874,17 +798,6 @@ export default function CreateAudio() {
forceFilter="image"
hideFilter
/>
<GalleryModal
mode="select"
isOpen={isAudioPickerOpen}
onClose={() => setIsAudioPickerOpen(false)}
selectedItemIds={audioPickerSelectedIds}
onSelectItem={handleAudioPickerSelect}
maxSelections={audioPickerMax}
onUseSelected={handleLibraryAudioSelect}
forceFilter="audio"
hideFilter
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
onClose={lightbox.closeLightbox}

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