Compare commits

..
Author SHA1 Message Date
Brandon Thomas fd8fe7f3db fix tests 2026-07-08 16:17:12 -04:00
Brandon Thomas 54b172bf6f test on api client 2026-07-08 16:08:44 -04:00
Brandon Thomas d7868c7da0 don't validate cost estimates 2026-07-08 15:44:56 -04:00
BFlat d538bc2a68 Add 3D Object & 3D World generation to the webapp (#1756)
Adds two new create pages for 3D generation, 3D Object (meshes) and 3D
World (gaussian splats), wired up to the new omni-gen mesh/splat
endpoints, plus the navigation, icons, and polish to go with them.

### New create pages
- 3D Object (`/create-object`): turn a prompt or image into a 3D model.
- 3D World (`/create-world`): turn a prompt or image into an explorable
world.
- Both mirror the existing Create Image/Video experience: prompt box
with model selector and per-model options in the bottom bar, and a live
feed of in-progress, completed, and failed generations.
- Options adapt to the selected model, e.g. output type, polygon type,
geometry/texture quality, face count, PBR and texture toggles,
multi-view reference angles (front/back/left/right), mesh-file input,
reference image/video, panorama, and prompt-only where supported.
- Multi-view and reference slots support the same Upload /
Pick-from-library flow (desktop hover and mobile) as the image rows.
- Completed cards show the model's cover screenshot; clicking one opens
it in the interactive 3D viewer.

### Navigation and discovery
- Sidebar reorganized into two groups: Create (Image, Video, Audio, 3D
Object, 3D World) and a new Studio group (Edit Image, Edit 3D, Edit
Video, BG Change, Moodboard).
- Added 3D Object and 3D World cards to the Home page.
- Added breadcrumbs for the new pages.
- Added a My Library button to the top bar, next to the credits and
task-queue chips.

### Model icons
- Restored the Hunyuan (Tencent) and World Labs provider icons.
- Added Tripo, Meshy, and Rodin (Hyper3D) icons.
- Applied to both the webapp and desktop app.

### Fixes and polish
- Fixed the 3D-model icons showing a generic placeholder (missing cyan
color plus creator mappings).
- Fixed the Upload / Pick-from-library hover popover being clipped
inside glass panels.
- Reference-image rows in the 3D pages now align their fields to the
right with the title on the left, matching the other create pages.
- Prevented 3D-model cover screenshots from showing up as their own "3D
object" cards across the 3D feeds, the Library page, and the gallery
picker.
2026-07-08 23:03:11 +07:00
BFlat bec8c7ede4 Add Create Audio pages (Suno + Seed Audio) with waveform gallery playback to desktop and webapp (#1748)
Frontend for the new omni audio API
(`/v1/omni_gen/{models,generate,cost}/audio`).

### New pages
- **Webapp**: `/create-audio` page (desktop layout + mobile
Generate/History tabs with settings drawers), added to sidebar.
- **Desktop**: new `AUDIO` tab ("Create Audio") with `PromptBoxAudio`,
registered in the app menu / TopBar. Audio enqueues over HTTP and polls
the jobs feed (Tauri task queue untouched).

### Models & promptbox
- Suno Music / Remix / Sounds / Sample + Seed Audio 1.0, fetched live
from `GET /v1/omni_gen/models/audio` with per-model capability gating.
- Always-visible **style prompt** row (Suno tags), instrumental /
keep-lyrics / loop toggles, BPM + musical key popover (Suno Sounds),
sample rate + speed/volume/pitch tuning popover (Seed Audio).
- Audio/image reference uploads with server rules mirrored client-side
(remix/sample require exactly 1 audio track; Seed Audio can't combine
audio + image refs).
- One request can return multiple job tokens (Suno multi-clip) - every
token becomes its own pending card.

### Audio playback in galleries
- New **`@storyteller/ui-audio-player`** lib (wavesurfer.js v7):
waveform + seek, play/pause, volume, duration - only one player plays at
a time.
- Lazy waveform decode (IntersectionObserver + peaks cache) and a
no-waveform `<audio>` fallback if CORS/decode fails.
- Audio cards/rows added once in the shared `ui-generation-list`, so
both hosts' grid + list views get them.
- Audio playback in both lightboxes (webapp lightbox + desktop
lightbox-modal); Library gets an **Audio** filter tab and includes audio
in "All"; downloads keep the right extension.

### Shared plumbing
- `OmniGenApi` gains audio types + `getAudioModels` / `generateAudio` /
`estimateAudioCost`.
- `useOmniGenAudioModels`, request builder, enqueue + cost hook shared
via `@storyteller/omni-gen` (used by both hosts).
- `useGenerationJobs` moved from the webapp into `ui-generation-list`
(with re-export shim) and taught the `audio_generation` category - the
desktop audio feed reuses it.
- Suno model names/descriptions/creator icon added to `model-list`.
2026-07-08 18:18:03 +07:00
Brandon Thomas 0866e63dd4 claude instruction 2026-07-08 02:26:52 -04:00
Brandon Thomas 78a5c4ff87 handle many more mesh models (#1755) 2026-07-08 01:02:37 -04:00
Brandon Thomas d53fad0072 user input errors on seedance (#1754) 2026-07-07 23:58:24 -04:00
Brandon Thomas 2e5e9443a3 fix models (#1753) 2026-07-07 20:29:24 -04:00
Brandon Thomas 5bda2a6cb4 mesh and splat omni endpoints (#1752) 2026-07-07 19:25:49 -04:00
Brandon Thomas 9cf1b8e066 Add omni audio! router, web, etc. - seed audio, suno, etc. (#1747) 2026-07-07 03:32:16 -04:00
Brandon Thomas aebc6ab777 seed audio client / (fal) (#1745) 2026-07-07 02:27:50 -04:00
Brandon Thomas d3c89face5 suno client (#1744) 2026-07-07 02:02:58 -04:00
Brandon Thomas 692e0ecf75 4k veo (#1743) 2026-07-07 01:27:53 -04:00
Brandon Thomas 94e1aef78d artcraft_router: Vidu and Veo models (#1742) 2026-07-06 23:51:08 -04:00
Brandon Thomas 9a4bb3b490 reduce num queries from 3 -> 1 (#1741) 2026-07-06 20:07:57 -04:00
Brandon Thomas 299d9b8d23 fix redis tx over database tx pooling issues (#1740) 2026-07-06 19:23:59 -04:00
Brandon Thomas 89eff46c6f database perf (#1739) 2026-07-06 16:53:11 -04:00
Brandon Thomas cec3b50ae4 characters API support (#1738) 2026-07-06 16:12:00 -04:00
BFlat 3040aaa0d1 Default og:image is now the ArtCraft logo banner (#1737) 2026-07-06 19:56:53 +07:00
BFlat f47e8b9b14 Default og:image is now the ArtCraft logo banner (#1736) 2026-07-06 19:52:50 +07:00
BFlat 83115e7ef9 Prompt character counters on web, seedance CJK limit waiver, and model creator mapping (#1735)
## Changes

- **Prompt character counters on web**: the website and webapp
promptboxes (image + video) now show a `123 / 2000` counter driven by
the API's `text_prompt_max_length`, matching the desktop app. It turns
red when over the limit and generation is blocked with a toast; typing
is never clamped. The webapp's fullscreen prompt editor shows the
counter too.
- **Seedance CJK waiver**: seedance video models normally use the API
limit, but the limit is waived (shows `∞`) while the prompt contains
Chinese characters. Applies on desktop and web via a shared
`effectivePromptMaxLength` helper.
- **API type parity**: added the new `model_creator` field to the web
`OmniGenImageModelInfo` / `OmniGenVideoModelInfo` types (desktop types
were already up to date).
- **Cleanup**: deleted the manual model-to-creator map
(`MODEL_TYPE_TO_CREATOR`, ~150 lines) now that creator info comes from
the API; lightbox model icons use the shared
`getCreatorIconPathForModelId` lookup like every other surface.
2026-07-06 19:42:56 +07:00
Brandon Thomas 4fbe9f54e0 more cleanup (#1734) 2026-07-04 11:53:00 -04:00
Brandon Thomas e850022fe8 further cleanup (#1733) 2026-07-04 11:43:49 -04:00
Brandon Thomas 1a528bba45 remove deprecated endpoints (#1732) 2026-07-04 10:15:59 -04:00
Brandon Thomas b625d5ff79 http header fields on alerts (#1731) 2026-07-03 20:59:52 -04:00
Brandon Thomas d8fd0bfe64 fix request logging (#1730) 2026-07-03 16:07:00 -04:00
Brandon Thomas 3cc02ae7bf admin dashboard spend (#1729) 2026-07-03 04:52:28 -04:00
Brandon Thomas 2c943b61ab improve media files user list query performance (#1728) 2026-07-03 04:10:13 -04:00
Brandon Thomas d92e6fc75c add index to media_files to aid lookups (#1727) 2026-07-03 03:55:53 -04:00
764 changed files with 47909 additions and 7351 deletions
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = CURRENT_TIMESTAMP,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "376e36b1357952599866262c7356ba35f15ccdfb338d8ccdbdc06ccd69331fc3"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = NULL,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "40a3301e9a3201e74d49b6b339f67291e2ae22d315188401708609ac2d28a68c"
}
@@ -1,47 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n job_type as queue_type,\n count(*) as pending_job_count,\n NOW() as present_time\n FROM (\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n AND job_type IS NOT NULL\n UNION\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"started\")\n AND created_at > (CURDATE() - INTERVAL 15 MINUTE)\n AND job_type IS NOT NULL\n) as generic_inner\nGROUP BY queue_type\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "queue_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "pending_job_count",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 2,
"name": "present_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false
]
},
"hash": "73dc054488b2fa3efc923d3a90914a1450b73e99a377562484a0498ccce1676f"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO tts_model_upload_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n creator_user_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n title = ?,\n tts_model_type = ?,\n download_url = ?,\n status = \"pending\"\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "886861e91c7e8af53eb856c949389a172b8d9e079f1c2070d115423907c47c50"
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = CURRENT_TIMESTAMP\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "961f792c24bee6009e3e8a215561db932871a53c48839b8cb80292de00e7b2e1"
}
@@ -1,135 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as job_token,\n\n jobs.status,\n jobs.attempt_count,\n jobs.on_success_result_token as maybe_result_token,\n results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,\n\n jobs.model_token,\n tts.tts_model_type,\n tts.title,\n\n jobs.raw_inference_text,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM tts_inference_jobs as jobs\nJOIN tts_models as tts\n ON tts.token = jobs.model_token\nLEFT OUTER JOIN tts_results as results\n ON jobs.on_success_result_token = results.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "attempt_count",
"type_info": {
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 3,
"name": "maybe_result_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_public_bucket_wav_audio_path",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "model_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "tts_model_type",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 60
}
},
{
"ordinal": 7,
"name": "title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "raw_inference_text",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 262140
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false
]
},
"hash": "a2b586dd2120f29dd26281626f7381e292c821f8e826b9bc584b92ffa45035b2"
}
@@ -1,69 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.username,\n users.display_name,\n users.email_gravatar_hash as gravatar_hash,\n creator_user_token,\n count(*) as uploaded_count\nFROM tts_models\nJOIN users\nON\n users.token = creator_user_token\nWHERE\n users.is_banned IS FALSE\n AND users.token NOT IN (\"U:DRB1SKEMKHT1J\")\n AND tts_models.user_deleted_at IS NULL\n AND tts_models.mod_deleted_at IS NULL\nGROUP BY creator_user_token\nORDER BY uploaded_count desc\nLIMIT 25;\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 1,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "creator_user_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "uploaded_count",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "a82fa4eb36528ebf52af2585cdda203476a4ba3645837c792830474fca2eda4f"
}
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n e.maybe_user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events e\nJOIN users u ON u.token = e.maybe_user_token\nWHERE e.maybe_user_token IS NOT NULL\n AND e.is_production = TRUE\n AND e.payment_occurred_at >= ?\n AND (? IS NULL OR e.payments_namespace = ?)\nGROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash\nORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_gravatar_hash!",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "gross_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 5,
"name": "refund_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 6,
"name": "net_spend_usd_cents!: i64",
"type_info": {
"type": "LongLong",
"flags": "BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 7,
"name": "payment_count!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 8,
"name": "credits_granted!: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
true,
false,
false,
false,
true,
true,
true,
true,
false
]
},
"hash": "c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8"
}
@@ -0,0 +1,91 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = NULL\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "dbce51eb1c80dded902c9509e50713483c7131f115e8ec10646e99e88110278b"
}
+17
View File
@@ -77,3 +77,20 @@ Organize for top-to-bottom reading. Important things first, details later.
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
## Markdown
- **Tables must be space-padded so columns align in plain text.** Markdown
tables are read raw (terminals, diffs, editors) at least as often as
rendered, and condensed tables are unreadable there. Pad every cell to its
column width:
```markdown
| Model | Configuration | Credits | Speed | Score |
|--------------|---------------|--------------------|---------|
| Meshy 6 | text or image | 104 | 80.0 | +24 |
| Rodin 2.5 | text or image | 13 | 10.0 | +3 |
```
Not: `| Model | Configuration | Credits | Speed | Score |` packed tight
with varying widths per row.
Generated
+1
View File
@@ -765,6 +765,7 @@ dependencies = [
"serde_json",
"strum",
"test_data",
"test_utils",
"tokens",
"tokio",
"url_utils",
@@ -1,5 +1,6 @@
pub mod alerts;
pub mod debug_logs;
pub mod top_spenders;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -0,0 +1,82 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str =
"/v1/moderation/top_spenders/list";
/// Rolling aggregation window for the top spenders list.
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
pub enum TopSpendersWindow {
#[serde(rename = "24h")]
TwentyFourHours,
#[serde(rename = "7d")]
SevenDays,
#[serde(rename = "14d")]
FourteenDays,
#[serde(rename = "30d")]
ThirtyDays,
#[serde(rename = "60d")]
SixtyDays,
#[serde(rename = "90d")]
NinetyDays,
#[serde(rename = "180d")]
OneHundredEightyDays,
#[serde(rename = "365d")]
ThreeHundredSixtyFiveDays,
}
impl TopSpendersWindow {
/// Window length in hours.
pub fn as_hours(self) -> i64 {
match self {
TopSpendersWindow::TwentyFourHours => 24,
TopSpendersWindow::SevenDays => 7 * 24,
TopSpendersWindow::FourteenDays => 14 * 24,
TopSpendersWindow::ThirtyDays => 30 * 24,
TopSpendersWindow::SixtyDays => 60 * 24,
TopSpendersWindow::NinetyDays => 90 * 24,
TopSpendersWindow::OneHundredEightyDays => 180 * 24,
TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24,
}
}
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListTopSpendersQueryParams {
/// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`,
/// `180d`, or `365d`.
pub window: Option<TopSpendersWindow>,
/// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListTopSpendersResponse {
pub success: bool,
/// The window the results were aggregated over (echoed back, post-default).
pub window: TopSpendersWindow,
pub spenders: Vec<TopSpenderEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct TopSpenderEntry {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
/// Sum of positive amounts in the window.
pub gross_spend_usd_cents: u64,
/// Refund/chargeback magnitude in the window (positive number).
pub refund_usd_cents: u64,
/// Gross minus refunds. Can be negative if refunds outweigh purchases.
pub net_spend_usd_cents: i64,
/// Number of positive money events in the window.
pub payment_count: u64,
/// Credits granted in the window.
pub credits_granted: u64,
}
@@ -0,0 +1 @@
pub mod list;
@@ -1,2 +1,5 @@
pub mod omni_gen_audio_cost_and_generate_request;
pub mod omni_gen_image_cost_and_generate_request;
pub mod omni_gen_mesh_cost_and_generate_request;
pub mod omni_gen_splat_cost_and_generate_request;
pub mod omni_gen_video_cost_and_generate_request;
@@ -0,0 +1,59 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_audio_model::CommonAudioModel;
use enums::common::generation::common_musical_key::CommonMusicalKey;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the audio cost estimate and audio generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenAudioCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonAudioModel>,
/// The text prompt for the audio generation.
pub prompt: Option<String>,
/// Style/genre direction (Suno's "tags").
pub style_prompt: Option<String>,
/// Reference audio (optional).
/// The remix/sample source, or Seed Audio @Audio references (up to 3).
pub audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Reference image (optional).
/// Seed Audio supports a single reference image; cannot be combined with audio references.
pub image_media_tokens: Option<Vec<MediaFileToken>>,
/// Whether to keep the original lyrics (Suno Remix).
pub keep_lyrics: Option<bool>,
/// Whether to generate instrumental-only audio (Suno Music / Sample).
pub is_instrumental: Option<bool>,
/// Whether the sound should loop vs a single hit (Suno Sounds).
pub is_loopable: Option<bool>,
/// Beats per minute (Suno Sounds).
pub bpm: Option<u16>,
/// The musical key to use (Suno Sounds).
pub musical_key: Option<CommonMusicalKey>,
/// Output sample rate in Hz (Seed Audio: 8000/16000/24000/32000/44100/48000).
pub sample_rate_hz: Option<u32>,
/// Playback speed multiplier (Seed Audio: 0.5–2.0).
pub speed: Option<f64>,
/// Volume multiplier (Seed Audio: 0.5–2.0).
pub volume: Option<f64>,
/// Pitch shift in semitones (Seed Audio: -12..=12).
pub pitch: Option<f64>,
}
@@ -0,0 +1,73 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_mesh_model::CommonMeshModel;
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the mesh cost estimate and mesh generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenMeshCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonMeshModel>,
/// The text prompt for the mesh generation (text-to-3D).
pub prompt: Option<String>,
/// Reference images (optional).
/// The primary/front input image, or the sketch image for sketch-to-3D.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Front view image (optional; multi-view input).
/// Can be used instead of `reference_image_media_tokens[0]` when the other
/// side views are supplied.
pub front_image_media_token: Option<MediaFileToken>,
/// Back view image (optional; multi-view input).
pub back_image_media_token: Option<MediaFileToken>,
/// Left view image (optional; multi-view input).
pub left_image_media_token: Option<MediaFileToken>,
/// Right view image (optional; multi-view input).
pub right_image_media_token: Option<MediaFileToken>,
/// Input mesh file for mesh-to-mesh models (part splitting, retopology).
/// Required by `hunyuan_3d_3p1_part` (FBX) and
/// `hunyuan_3d_3p1_topology` (GLB/OBJ).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_mesh_media_token: Option<MediaFileToken>,
/// The mesh output type to generate (normal, low-poly, or geometry-only).
pub mesh_output_type: Option<CommonMeshOutputType>,
/// The polygon type to use for the generated mesh (triangle or quad).
pub polygon_type: Option<CommonPolygonType>,
/// Target face count for the generated mesh.
pub face_count: Option<u64>,
/// Whether to generate PBR (physically based rendering) materials.
pub enable_pbr: Option<bool>,
/// Whether to generate textures. Some models (Tripo3D) can skip texturing
/// for a cheaper, faster generation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_texture: Option<bool>,
/// Texture quality. `Detailed` costs extra on some models (Tripo3D's HD
/// texture tier).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture_quality: Option<CommonMeshQuality>,
/// Geometry quality. `Detailed` costs extra on some models (Tripo3D).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geometry_quality: Option<CommonMeshQuality>,
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_splat_model::CommonSplatModel;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the splat cost estimate and splat generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenSplatCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonSplatModel>,
/// The text prompt for the splat generation.
pub prompt: Option<String>,
/// Reference images (optional).
/// NOTE: Spherical/panorama coordinates are not yet supported per-image;
/// multiple images are treated as multi-view input of the same world.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Reference video (optional).
pub reference_video_media_token: Option<MediaFileToken>,
/// Whether the single reference image is a 360-degree panorama.
pub is_panoramic: Option<bool>,
/// Whether to disable prompt recaptioning.
pub disable_recaption: Option<bool>,
}
@@ -1,2 +1,5 @@
pub mod omni_gen_audio_cost_response;
pub mod omni_gen_image_cost_response;
pub mod omni_gen_mesh_cost_response;
pub mod omni_gen_splat_cost_response;
pub mod omni_gen_video_cost_response;
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen audio cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenAudioCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen mesh cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenMeshCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen splat cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenSplatCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen video cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct OmniGenVideoCostResponse {
pub success: bool,
@@ -1,2 +1,5 @@
pub mod omni_gen_audio_generate_response;
pub mod omni_gen_image_generate_response;
pub mod omni_gen_mesh_generate_response;
pub mod omni_gen_splat_generate_response;
pub mod omni_gen_video_generate_response;
@@ -0,0 +1,16 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen audio generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenAudioGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -0,0 +1,16 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen mesh generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenMeshGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -0,0 +1,16 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen splat generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenSplatGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -1,2 +1,5 @@
pub mod omni_gen_audio_models;
pub mod omni_gen_image_models;
pub mod omni_gen_mesh_models;
pub mod omni_gen_splat_models;
pub mod omni_gen_video_models;
@@ -0,0 +1,139 @@
use enums::common::generation::common_audio_model::CommonAudioModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Audio model to default to if none is specified
const DEFAULT_AUDIO_MODEL : CommonAudioModel = CommonAudioModel::SunoMusic;
/// Response body for the audio models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenAudioModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenAudioModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenAudioProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioProviderModelDetails {
pub model: CommonAudioModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenAudioModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenAudioModelDetails {
pub model: CommonAudioModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
/// Whether a style/genre prompt (Suno's "tags") is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub style_prompt_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_max: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_max: Option<u16>,
/// Whether the "keep lyrics" toggle is supported (Suno Remix).
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_lyrics_supported: Option<bool>,
/// Whether the instrumental-only toggle is supported (Suno Music / Sample).
#[serde(skip_serializing_if = "Option::is_none")]
pub instrumental_toggle_supported: Option<bool>,
/// Whether the loop vs single-hit toggle is supported (Suno Sounds).
#[serde(skip_serializing_if = "Option::is_none")]
pub loopable_toggle_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bpm_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub musical_key_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate_hz_options: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate_hz_default: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pitch_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenAudioModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_AUDIO_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
style_prompt_supported: None,
audio_references_supported: None,
audio_references_max: None,
image_references_supported: None,
image_references_max: None,
keep_lyrics_supported: None,
instrumental_toggle_supported: None,
loopable_toggle_supported: None,
bpm_supported: None,
musical_key_supported: None,
sample_rate_hz_options: None,
sample_rate_hz_default: None,
speed_supported: None,
volume_supported: None,
pitch_supported: None,
is_disabled: None,
}
}
}
@@ -0,0 +1,134 @@
use enums::common::generation::common_mesh_model::CommonMeshModel;
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Mesh model to default to if none is specified
const DEFAULT_MESH_MODEL : CommonMeshModel = CommonMeshModel::Hunyuan3d3;
/// Response body for the mesh models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenMeshModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenMeshModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenMeshProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshProviderModelDetails {
pub model: CommonMeshModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenMeshModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelDetails {
pub model: CommonMeshModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
/// Whether text-to-3D prompting is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
/// Whether image-to-3D input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub image_input_supported: Option<bool>,
/// Whether sketch-to-3D input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub sketch_input_supported: Option<bool>,
/// Whether multi-view (front/back/left/right) image input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_view_supported: Option<bool>,
/// Whether the model takes an existing mesh file as input
/// (mesh-to-mesh models like part splitting and retopology).
#[serde(skip_serializing_if = "Option::is_none")]
pub mesh_input_supported: Option<bool>,
/// The mesh output types the model supports.
#[serde(skip_serializing_if = "Option::is_none")]
pub mesh_output_types: Option<Vec<CommonMeshOutputType>>,
/// The polygon types the model supports.
#[serde(skip_serializing_if = "Option::is_none")]
pub polygon_types: Option<Vec<CommonPolygonType>>,
/// Whether a target face count can be specified.
#[serde(skip_serializing_if = "Option::is_none")]
pub face_count_supported: Option<bool>,
/// Whether PBR (physically based rendering) material generation is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub pbr_supported: Option<bool>,
/// Whether texture generation can be toggled off (untextured output).
#[serde(skip_serializing_if = "Option::is_none")]
pub texture_toggle_supported: Option<bool>,
/// Whether a texture quality level (standard/detailed) can be selected.
#[serde(skip_serializing_if = "Option::is_none")]
pub texture_quality_supported: Option<bool>,
/// Whether a geometry quality level (standard/detailed) can be selected.
#[serde(skip_serializing_if = "Option::is_none")]
pub geometry_quality_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenMeshModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_MESH_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
image_input_supported: None,
sketch_input_supported: None,
multi_view_supported: None,
mesh_input_supported: None,
mesh_output_types: None,
polygon_types: None,
face_count_supported: None,
pbr_supported: None,
texture_toggle_supported: None,
texture_quality_supported: None,
geometry_quality_supported: None,
is_disabled: None,
}
}
}
@@ -0,0 +1,98 @@
use enums::common::generation::common_splat_model::CommonSplatModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Splat model to default to if none is specified
const DEFAULT_SPLAT_MODEL : CommonSplatModel = CommonSplatModel::Marble1p1;
/// Response body for the splat models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenSplatModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenSplatModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenSplatProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatProviderModelDetails {
pub model: CommonSplatModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenSplatModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelDetails {
pub model: CommonSplatModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_max: Option<u16>,
/// Whether a reference video input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub video_reference_supported: Option<bool>,
/// Whether a 360-degree panorama reference image is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub panorama_supported: Option<bool>,
/// Whether the "disable recaption" toggle is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_recaption_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenSplatModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_SPLAT_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
image_references_supported: None,
image_references_max: None,
video_reference_supported: None,
panorama_supported: None,
disable_recaption_supported: None,
is_disabled: None,
}
}
}
@@ -0,0 +1 @@
pub mod omni_gen_audio;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_audio_generate_response::OmniGenAudioGenerateResponse;
pub const OMNI_GEN_AUDIO_GENERATE_PATH: &str = "/v1/omni_gen/generate/audio";
pub async fn omni_gen_audio_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenAudioCostAndGenerateRequest,
) -> Result<OmniGenAudioGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_AUDIO_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod omni_gen_mesh;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse;
pub const OMNI_GEN_MESH_GENERATE_PATH: &str = "/v1/omni_gen/generate/mesh";
pub async fn omni_gen_mesh_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenMeshCostAndGenerateRequest,
) -> Result<OmniGenMeshGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_MESH_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,2 +1,5 @@
pub mod audio;
pub mod image;
pub mod mesh;
pub mod splat;
pub mod video;
@@ -0,0 +1 @@
pub mod omni_gen_splat;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse;
pub const OMNI_GEN_SPLAT_GENERATE_PATH: &str = "/v1/omni_gen/generate/splat";
pub async fn omni_gen_splat_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenSplatCostAndGenerateRequest,
) -> Result<OmniGenSplatGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_SPLAT_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -39,6 +39,7 @@ uuid.workspace = true
[dev-dependencies]
test_data.workspace = true
test_utils.workspace = true
strum.workspace = true
serde_json.workspace = true
@@ -0,0 +1,9 @@
use tokens::tokens::media_files::MediaFileToken;
/// Reference to an input 3D mesh file (e.g. GLB/OBJ/FBX) for mesh-to-mesh
/// models like part splitting and retopology.
#[derive(Clone, Debug)]
pub enum MeshRef {
MediaFileToken(MediaFileToken),
Url(String),
}
@@ -2,12 +2,16 @@ pub mod audio_list_ref;
pub mod character_list_ref;
pub mod image_list_ref;
pub mod image_ref;
pub mod mesh_ref;
pub mod router_aspect_ratio;
pub mod router_audio_model;
pub mod router_bitrate;
pub mod router_image_model;
pub mod router_mesh_model;
pub mod router_provider;
pub mod router_quality;
pub mod router_resolution;
pub mod router_splat_model;
pub mod router_video_model;
pub mod video_list_ref;
pub mod video_ref;
@@ -0,0 +1,65 @@
use serde_derive::{Deserialize, Serialize};
/// Common audio models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterAudioModel {
#[serde(rename = "suno_music")]
SunoMusic,
#[serde(rename = "suno_remix")]
SunoRemix,
#[serde(rename = "suno_sounds")]
SunoSounds,
#[serde(rename = "suno_sample")]
SunoSample,
#[serde(rename = "seed_audio_1p0")]
SeedAudio1p0,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonAudioModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_audio_model_strings() {
assert_serde_round_trip(RouterAudioModel::SunoMusic, "suno_music");
assert_serde_round_trip(RouterAudioModel::SunoRemix, "suno_remix");
assert_serde_round_trip(RouterAudioModel::SunoSounds, "suno_sounds");
assert_serde_round_trip(RouterAudioModel::SunoSample, "suno_sample");
assert_serde_round_trip(RouterAudioModel::SeedAudio1p0, "seed_audio_1p0");
}
#[test]
fn round_trips_through_common_audio_model() {
use enums::common::generation::common_audio_model::CommonAudioModel;
let cases = [
(RouterAudioModel::SunoMusic, CommonAudioModel::SunoMusic),
(RouterAudioModel::SunoRemix, CommonAudioModel::SunoRemix),
(RouterAudioModel::SunoSounds, CommonAudioModel::SunoSounds),
(RouterAudioModel::SunoSample, CommonAudioModel::SunoSample),
(RouterAudioModel::SeedAudio1p0, CommonAudioModel::SeedAudio1p0),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonAudioModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonAudioModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterAudioModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterAudioModel = serde_json::from_str(&json).unwrap();
// RouterAudioModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -0,0 +1,107 @@
use serde_derive::{Deserialize, Serialize};
/// Common mesh (3D object) models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterMeshModel {
#[serde(rename = "hunyuan_3d_2p0")]
Hunyuan3d2p0,
#[serde(rename = "hunyuan_3d_2p1")]
Hunyuan3d2p1,
#[serde(rename = "hunyuan_3d_3")]
Hunyuan3d3,
/// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as
/// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input.
#[serde(rename = "hunyuan_3d_3_sketch")]
Hunyuan3d3Sketch,
/// Hunyuan 3D v3.1 Pro: text or (multi-view) image input.
#[serde(rename = "hunyuan_3d_3p1_pro")]
Hunyuan3d3p1Pro,
/// Hunyuan 3D v3.1 Rapid: the fast, low-cost tier. Text or single-image
/// input.
#[serde(rename = "hunyuan_3d_3p1_rapid")]
Hunyuan3d3p1Rapid,
/// Hunyuan 3D v3.1 Part: splits an existing mesh into semantic parts.
/// Takes a mesh file as input, not text/images.
#[serde(rename = "hunyuan_3d_3p1_part")]
Hunyuan3d3p1Part,
/// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing mesh.
/// Takes a mesh file as input, not text/images.
#[serde(rename = "hunyuan_3d_3p1_topology")]
Hunyuan3d3p1SmartTopology,
/// Tripo3D H3.1: text, single-image, or multi-view image input.
#[serde(rename = "tripo3d_h3p1")]
Tripo3dH3p1,
/// Meshy 6: text or single-image input.
#[serde(rename = "meshy_v6")]
MeshyV6,
/// Hyper3D Rodin v2.5 Fast: text or image(s) input.
#[serde(rename = "rodin_2p5_fast")]
Rodin2p5Fast,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonMeshModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_mesh_model_strings() {
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3, "hunyuan_3d_3");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Pro, "hunyuan_3d_3p1_pro");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Rapid, "hunyuan_3d_3p1_rapid");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Part, "hunyuan_3d_3p1_part");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1SmartTopology, "hunyuan_3d_3p1_topology");
assert_serde_round_trip(RouterMeshModel::Tripo3dH3p1, "tripo3d_h3p1");
assert_serde_round_trip(RouterMeshModel::MeshyV6, "meshy_v6");
assert_serde_round_trip(RouterMeshModel::Rodin2p5Fast, "rodin_2p5_fast");
}
#[test]
fn round_trips_through_common_mesh_model() {
use enums::common::generation::common_mesh_model::CommonMeshModel;
let cases = [
(RouterMeshModel::Hunyuan3d2p0, CommonMeshModel::Hunyuan3d2p0),
(RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1),
(RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3),
(RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch),
(RouterMeshModel::Hunyuan3d3p1Pro, CommonMeshModel::Hunyuan3d3p1Pro),
(RouterMeshModel::Hunyuan3d3p1Rapid, CommonMeshModel::Hunyuan3d3p1Rapid),
(RouterMeshModel::Hunyuan3d3p1Part, CommonMeshModel::Hunyuan3d3p1Part),
(RouterMeshModel::Hunyuan3d3p1SmartTopology, CommonMeshModel::Hunyuan3d3p1SmartTopology),
(RouterMeshModel::Tripo3dH3p1, CommonMeshModel::Tripo3dH3p1),
(RouterMeshModel::MeshyV6, CommonMeshModel::MeshyV6),
(RouterMeshModel::Rodin2p5Fast, CommonMeshModel::Rodin2p5Fast),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonMeshModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonMeshModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterMeshModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterMeshModel = serde_json::from_str(&json).unwrap();
// RouterMeshModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -9,4 +9,5 @@ pub enum RouterProvider {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
@@ -5,9 +5,68 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterSplatModel {
/// Deprecated upstream; treated as [`Self::Marble1p0Draft`].
#[serde(rename = "marble_0p1_mini")]
Marble0p1Mini,
/// Deprecated upstream; treated as [`Self::Marble1p0`].
#[serde(rename = "marble_0p1_plus")]
Marble0p1Plus,
#[serde(rename = "marble_1p0")]
Marble1p0,
#[serde(rename = "marble_1p0_draft")]
Marble1p0Draft,
#[serde(rename = "marble_1p1")]
Marble1p1,
#[serde(rename = "marble_1p1_plus")]
Marble1p1Plus,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonSplatModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_splat_model_strings() {
assert_serde_round_trip(RouterSplatModel::Marble0p1Mini, "marble_0p1_mini");
assert_serde_round_trip(RouterSplatModel::Marble0p1Plus, "marble_0p1_plus");
assert_serde_round_trip(RouterSplatModel::Marble1p0, "marble_1p0");
assert_serde_round_trip(RouterSplatModel::Marble1p0Draft, "marble_1p0_draft");
assert_serde_round_trip(RouterSplatModel::Marble1p1, "marble_1p1");
assert_serde_round_trip(RouterSplatModel::Marble1p1Plus, "marble_1p1_plus");
}
#[test]
fn round_trips_through_common_splat_model() {
use enums::common::generation::common_splat_model::CommonSplatModel;
let cases = [
(RouterSplatModel::Marble0p1Mini, CommonSplatModel::Marble0p1Mini),
(RouterSplatModel::Marble0p1Plus, CommonSplatModel::Marble0p1Plus),
(RouterSplatModel::Marble1p0, CommonSplatModel::Marble1p0),
(RouterSplatModel::Marble1p0Draft, CommonSplatModel::Marble1p0Draft),
(RouterSplatModel::Marble1p1, CommonSplatModel::Marble1p1),
(RouterSplatModel::Marble1p1Plus, CommonSplatModel::Marble1p1Plus),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonSplatModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonSplatModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterSplatModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterSplatModel = serde_json::from_str(&json).unwrap();
// RouterSplatModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -98,6 +98,15 @@ pub enum RouterVideoModel {
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "veo_3p1_lite")]
Veo3p1Lite,
#[serde(rename = "vidu_q3")]
ViduQ3,
#[serde(rename = "vidu_q3_turbo")]
ViduQ3Turbo,
#[serde(rename = "preview_model")]
PreviewModel,
@@ -123,4 +132,13 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite");
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
}
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Clone, Debug)]
pub enum VideoRef {
MediaFileToken(MediaFileToken),
Url(String),
}
@@ -8,3 +8,4 @@ pub mod router_fal_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_seedance2pro_client;
pub mod router_worldlabs_client;
@@ -3,6 +3,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
@@ -11,6 +12,7 @@ pub struct MultiRouterClient {
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
}
impl MultiRouterClient {
@@ -38,4 +40,9 @@ impl MultiRouterClient {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
self.worldlabs_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::WorldLabs))
}
}
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
@@ -11,6 +12,7 @@ pub struct MultiRouterClientBuilder {
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
impl MultiRouterClientBuilder {
@@ -21,6 +23,7 @@ impl MultiRouterClientBuilder {
gmicloud_client: None,
grok_api_client: None,
seedance2pro_client: None,
worldlabs_client: None,
}
}
@@ -49,6 +52,11 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_worldlabs_client(mut self, client: RouterWorldLabsClient) -> Self {
self.worldlabs_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
@@ -56,6 +64,7 @@ impl MultiRouterClientBuilder {
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
seedance2pro_client: self.seedance2pro_client,
worldlabs_client: self.worldlabs_client,
}
}
}
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
pub enum RouterClient {
@@ -13,6 +14,7 @@ pub enum RouterClient {
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Seedance2Pro(RouterSeedance2ProClient),
WorldLabs(RouterWorldLabsClient),
}
impl RouterClient {
@@ -55,4 +57,12 @@ impl RouterClient {
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
}
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
match self {
RouterClient::WorldLabs(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_worldlabs_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::WorldLabs)),
}
}
}
@@ -0,0 +1,16 @@
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
/// A client for the official World Labs (Marble) API.
pub struct RouterWorldLabsClient {
pub(crate) creds: WorldLabsApiCreds,
}
impl RouterWorldLabsClient {
pub fn new(creds: WorldLabsApiCreds) -> Self {
Self { creds }
}
pub fn new_from_raw_key(api_key: &str) -> Self {
Self::new(WorldLabsApiCreds::new(api_key.to_string()))
}
}
@@ -10,6 +10,7 @@ pub enum ClientType {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
impl Display for ClientType {
@@ -20,6 +21,7 @@ impl Display for ClientType {
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
Self::WorldLabs => write!(f, "WorldLabs"),
}
}
}
@@ -5,6 +5,7 @@ use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
#[derive(Debug)]
pub enum ProviderError {
@@ -13,6 +14,7 @@ pub enum ProviderError {
GmiCloud(GmiCloudError),
Grok(GrokError),
Seedance2Pro(Seedance2ProError),
WorldLabs(WorldLabsError),
}
impl Error for ProviderError {}
@@ -25,6 +27,7 @@ impl Display for ProviderError {
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
}
}
}
@@ -58,3 +61,9 @@ impl From<Seedance2ProError> for ProviderError {
Self::Seedance2Pro(error)
}
}
impl From<WorldLabsError> for ProviderError {
fn from(error: WorldLabsError) -> Self {
Self::WorldLabs(error)
}
}
@@ -0,0 +1,16 @@
/// The result of estimating the cost of an audio generation plan.
#[derive(Clone, Debug)]
pub struct AudioGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -0,0 +1,56 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
/**
* Wrapper for all audio generation draft requests.
*
* Only the Kinovi remix/sample models need a draft phase: they take a user
* audio reference that must be downloaded from our CDN and re-uploaded to the
* Kinovi CDN before the request can be sent. All other audio providers return
* a `Request` directly from `build2()`.
*/
#[derive(Clone, Debug)]
pub enum AudioGenerationDraftRequest {
KinoviSunoRemix(KinoviSunoRemixDraftState),
KinoviSunoSample(KinoviSunoSampleDraftState),
}
impl AudioGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationDraftRequest::KinoviSunoRemix(draft) => Ok(KinoviSunoRemixCostState::from_draft(draft).estimate_cost()),
AudioGenerationDraftRequest::KinoviSunoSample(draft) => Ok(KinoviSunoSampleCostState::from_draft(draft).estimate_cost()),
}
}
/// Finalize the draft request before generation
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: AudioGenerationDraftContext<'_>) -> Result<AudioGenerationRequest, ArtcraftRouterError> {
match self {
AudioGenerationDraftRequest::KinoviSunoRemix(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(AudioGenerationRequest::KinoviSunoRemix(result))
},
AudioGenerationDraftRequest::KinoviSunoSample(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(AudioGenerationRequest::KinoviSunoSample(result))
},
}
}
}
@@ -0,0 +1,36 @@
use std::collections::HashMap;
use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
#[derive(Clone, Default)]
pub struct AudioGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require authentication.
pub client: Option<&'a RouterClient>,
/// Optional context: a map of Media File Tokens to their ArtCraft URLs
/// Only needed if we have to fetch these assets and upload them to another provider.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
}
impl <'a> AudioGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
}
impl Debug for AudioGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
.finish()
}
}
@@ -0,0 +1,28 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
#[derive(Debug, Clone)]
pub enum AudioGenerationDraftOrRequest {
Draft(AudioGenerationDraftRequest),
Request(AudioGenerationRequest),
}
impl AudioGenerationDraftOrRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::Draft(draft) => draft.get_provider(),
Self::Request(request) => request.get_provider(),
}
}
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
AudioGenerationDraftOrRequest::Request(request) => request.estimate_cost(),
}
}
}
@@ -0,0 +1,120 @@
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::cost::ArtcraftSeedAudio1p0CostState;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::artcraft::suno_music::cost::ArtcraftSunoMusicCostState;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_remix::cost::ArtcraftSunoRemixCostState;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_sample::cost::ArtcraftSunoSampleCostState;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::cost::ArtcraftSunoSoundsCostState;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::cost::FalSeedAudio1p0CostState;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::kinovi::suno_music::cost::KinoviSunoMusicCostState;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::cost::KinoviSunoRemixCostState;
use crate::generate::generate_audio::providers::kinovi::suno_remix::request::KinoviSunoRemixRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::cost::KinoviSunoSampleCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sample::request::KinoviSunoSampleRequestState;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::cost::KinoviSunoSoundsCostState;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::request::KinoviSunoSoundsRequestState;
#[derive(Clone, Debug)]
pub enum AudioGenerationRequest {
ArtcraftSunoMusic(ArtcraftSunoMusicRequestState),
ArtcraftSunoRemix(ArtcraftSunoRemixRequestState),
ArtcraftSunoSounds(ArtcraftSunoSoundsRequestState),
ArtcraftSunoSample(ArtcraftSunoSampleRequestState),
ArtcraftSeedAudio1p0(ArtcraftSeedAudio1p0RequestState),
FalSeedAudio1p0(FalSeedAudio1p0RequestState),
KinoviSunoMusic(KinoviSunoMusicRequestState),
KinoviSunoRemix(KinoviSunoRemixRequestState),
KinoviSunoSounds(KinoviSunoSoundsRequestState),
KinoviSunoSample(KinoviSunoSampleRequestState),
}
impl AudioGenerationRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::ArtcraftSunoMusic(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoRemix(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoSounds(_) => RouterProvider::Artcraft,
Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft,
Self::FalSeedAudio1p0(_) => RouterProvider::Fal,
Self::KinoviSunoMusic(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSounds(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<AudioGenerationCostEstimate, ArtcraftRouterError> {
match self {
AudioGenerationRequest::ArtcraftSunoMusic(request) => Ok(ArtcraftSunoMusicCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoRemix(request) => Ok(ArtcraftSunoRemixCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoSounds(request) => Ok(ArtcraftSunoSoundsCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSunoSample(request) => Ok(ArtcraftSunoSampleCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => Ok(ArtcraftSeedAudio1p0CostState::from_request(request).estimate_cost()),
AudioGenerationRequest::FalSeedAudio1p0(request) => Ok(FalSeedAudio1p0CostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoMusic(request) => Ok(KinoviSunoMusicCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoRemix(request) => Ok(KinoviSunoRemixCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoSounds(request) => Ok(KinoviSunoSoundsCostState::from_request(request).estimate_cost()),
AudioGenerationRequest::KinoviSunoSample(request) => Ok(KinoviSunoSampleCostState::from_request(request).estimate_cost()),
}
}
/// Send the audio generation request
/// If successful, returns the job IDs.
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
match self {
AudioGenerationRequest::ArtcraftSunoMusic(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoRemix(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoSounds(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSunoSample(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::ArtcraftSeedAudio1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::FalSeedAudio1p0(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoMusic(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoRemix(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSounds(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSample(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
}
}
}
@@ -0,0 +1,302 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::build::build_artcraft_seed_audio_1p0;
use crate::generate::generate_audio::providers::artcraft::suno_music::build::build_artcraft_suno_music;
use crate::generate::generate_audio::providers::artcraft::suno_remix::build::build_artcraft_suno_remix;
use crate::generate::generate_audio::providers::artcraft::suno_sample::build::build_artcraft_suno_sample;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::build::build_artcraft_suno_sounds;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0;
use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music;
use crate::generate::generate_audio::providers::kinovi::suno_remix::build::build_kinovi_suno_remix;
use crate::generate::generate_audio::providers::kinovi::suno_sample::build::build_kinovi_suno_sample;
use crate::generate::generate_audio::providers::kinovi::suno_sounds::build::build_kinovi_suno_sounds;
/// RouterProvider-agnostic audio generation request. Distilled by `build2()`
/// into an `AudioGenerationDraftOrRequest` for the selected (provider, model)
/// pair.
#[derive(Clone, Debug)]
pub struct GenerateAudioRequestBuilder {
/// Which model to use.
pub model: RouterAudioModel,
/// Which provider to use.
pub provider: RouterProvider,
/// The prompt for the audio generation.
pub prompt: Option<String>,
/// Style/genre direction (Suno's "tags"), e.g. "EDM style meets dance".
pub style_prompt: Option<String>,
/// Reference audio (optional).
/// The remix/sample source, or Seed Audio @Audio references (up to 3).
pub audio_references: Option<AudioListRef>,
/// Reference images (optional).
/// Seed Audio supports a single reference image; it cannot be combined
/// with audio references.
pub image_references: Option<ImageListRef>,
/// Whether to keep the original lyrics (Suno Remix).
pub keep_lyrics: Option<bool>,
/// Whether to generate instrumental-only audio (Suno Music / Sample).
pub is_instrumental: Option<bool>,
/// Whether the sound should loop vs a single hit (Suno Sounds).
pub is_loopable: Option<bool>,
/// Beats per minute (Suno Sounds).
pub bpm: Option<u16>,
/// The musical key to use (Suno Sounds).
pub musical_key: Option<CommonMusicalKey>,
/// Output sample rate in Hz (Seed Audio).
pub sample_rate_hz: Option<u32>,
/// Playback speed multiplier (Seed Audio: 0.5–2.0).
pub speed: Option<f64>,
/// Volume multiplier (Seed Audio: 0.5–2.0).
pub volume: Option<f64>,
/// Pitch shift in semitones (Seed Audio: -12..=12).
pub pitch: Option<f64>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
}
impl Default for GenerateAudioRequestBuilder {
fn default() -> Self {
Self {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
style_prompt: None,
audio_references: None,
image_references: None,
keep_lyrics: None,
is_instrumental: None,
is_loopable: None,
bpm: None,
musical_key: None,
sample_rate_hz: None,
speed: None,
volume: None,
pitch: None,
idempotency_token: None,
}
}
}
impl GenerateAudioRequestBuilder {
pub fn build2(self) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(RouterProvider::Artcraft, RouterAudioModel::SunoMusic) => build_artcraft_suno_music(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoRemix) => build_artcraft_suno_remix(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoSounds) => build_artcraft_suno_sounds(self),
(RouterProvider::Artcraft, RouterAudioModel::SunoSample) => build_artcraft_suno_sample(self),
(RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0) => build_artcraft_seed_audio_1p0(self),
// Fal
(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self),
// Kinovi
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
_ => self.unsupported_provider_and_model(),
}
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
fn unsupported_provider_and_model(&self) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Audio generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
}
#[cfg(test)]
mod tests {
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use super::*;
mod dispatch_tests {
use super::*;
#[test]
fn artcraft_suno_music_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(_))
));
}
#[test]
fn artcraft_suno_remix_dispatches() {
let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(_))
));
}
#[test]
fn artcraft_suno_sounds_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(_))
));
}
#[test]
fn artcraft_suno_sample_dispatches() {
let result = builder_with_audio_token_ref(RouterProvider::Artcraft, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(_))
));
}
#[test]
fn artcraft_seed_audio_1p0_dispatches() {
let result = builder(RouterProvider::Artcraft, RouterAudioModel::SeedAudio1p0).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(_))
));
}
#[test]
fn fal_seed_audio_1p0_dispatches() {
let result = builder(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(_))
));
}
#[test]
fn kinovi_suno_music_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_))
));
}
#[test]
fn kinovi_suno_sounds_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_))
));
}
#[test]
fn kinovi_suno_remix_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_))
));
}
#[test]
fn kinovi_suno_sample_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_))
));
}
}
mod unsupported_combo_tests {
use super::*;
#[test]
fn kinovi_seed_audio_1p0_is_unsupported() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SeedAudio1p0).build2();
assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))));
}
#[test]
fn fal_suno_models_are_unsupported() {
for model in [
RouterAudioModel::SunoMusic,
RouterAudioModel::SunoRemix,
RouterAudioModel::SunoSounds,
RouterAudioModel::SunoSample,
] {
let result = builder(RouterProvider::Fal, model).build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for Fal + {model:?}",
);
}
}
#[test]
fn gmicloud_and_grok_are_unsupported() {
for provider in [RouterProvider::GmiCloud, RouterProvider::GrokApi] {
let result = builder(provider, RouterAudioModel::SunoMusic).build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for {provider:?}",
);
}
}
}
// ── Helpers ──
fn builder(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
provider,
model,
prompt: Some("a song about corgis".to_string()),
..Default::default()
}
}
fn builder_with_audio_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..builder(provider, model)
}
}
fn builder_with_audio_token_ref(provider: RouterProvider, model: RouterAudioModel) -> GenerateAudioRequestBuilder {
use tokens::tokens::media_files::MediaFileToken;
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![MediaFileToken::new("mf_test123".to_string())])),
..builder(provider, model)
}
}
}
@@ -0,0 +1,65 @@
use std::fmt::Debug;
use std::sync::Arc;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct ArtcraftAudioResponsePayload {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Clone, Debug)]
pub struct Seedance2proAudioResponsePayload {
pub order_id: String,
pub task_id: String,
}
#[derive(Clone, Debug)]
pub struct FalAudioResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// Queue-mode status URL (fal's queue/polling flow). `None` for webhook
/// dispatch — the webhook callback drives status updates instead.
pub maybe_status_url: Option<String>,
/// Queue-mode response URL (fal's queue/polling flow). `None` for webhook
/// dispatch.
pub maybe_response_url: Option<String>,
/// The outbound request that was sent to Fal.
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub enum GenerateAudioResponse {
Artcraft(ArtcraftAudioResponsePayload),
Seedance2Pro(Seedance2proAudioResponsePayload),
Fal(FalAudioResponsePayload),
}
impl GenerateAudioResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftAudioResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proAudioResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalAudioResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -0,0 +1,8 @@
pub mod audio_generation_cost_estimate;
pub mod audio_generation_draft;
pub mod audio_generation_draft_context;
pub mod audio_generation_draft_or_request;
pub mod audio_generation_request;
pub mod generate_audio_request_builder;
pub mod generate_audio_response;
pub mod providers;
@@ -0,0 +1,143 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::resolve::{
resolve_audio_list_ref, resolve_image_list_ref,
};
/// Build an `OmniGenAudioCostAndGenerateRequest` from the builder. The
/// Artcraft provider is a thin forwarding shim: the storyteller-web omni
/// audio endpoint owns per-model validation and option mapping, so the
/// builder's options are passed through as-is.
pub fn build_artcraft_omni_audio_request(
mut builder: GenerateAudioRequestBuilder,
model: CommonAudioModelEnum,
) -> Result<OmniGenAudioCostAndGenerateRequest, ArtcraftRouterError> {
let audio_media_tokens = resolve_audio_list_ref(builder.audio_references.take())?;
let image_media_tokens = resolve_image_list_ref(builder.image_references.take())?;
let idempotency_token = builder.get_or_generate_idempotency_token();
Ok(OmniGenAudioCostAndGenerateRequest {
idempotency_token: Some(idempotency_token),
model: Some(model),
prompt: builder.prompt.take(),
style_prompt: builder.style_prompt.take(),
audio_media_tokens,
image_media_tokens,
keep_lyrics: builder.keep_lyrics,
is_instrumental: builder.is_instrumental,
is_loopable: builder.is_loopable,
bpm: builder.bpm,
musical_key: builder.musical_key,
sample_rate_hz: builder.sample_rate_hz,
speed: builder.speed,
volume: builder.volume,
pitch: builder.pitch,
})
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::errors::client_error::ClientError;
use super::*;
#[test]
fn all_fields_are_forwarded() {
use enums::common::generation::common_musical_key::CommonMusicalKey;
let builder = GenerateAudioRequestBuilder {
prompt: Some("a song".to_string()),
style_prompt: Some("EDM".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
image_references: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image".to_string()),
])),
keep_lyrics: Some(true),
is_instrumental: Some(false),
is_loopable: Some(true),
bpm: Some(120),
musical_key: Some(CommonMusicalKey::AMinor),
sample_rate_hz: Some(48_000),
speed: Some(1.5),
volume: Some(0.8),
pitch: Some(-2.0),
idempotency_token: Some("test-token".to_string()),
..base_builder()
};
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic)
.expect("build");
assert_eq!(request.idempotency_token.as_deref(), Some("test-token"));
assert_eq!(request.model, Some(CommonAudioModelEnum::SunoMusic));
assert_eq!(request.prompt.as_deref(), Some("a song"));
assert_eq!(request.style_prompt.as_deref(), Some("EDM"));
assert_eq!(request.audio_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.image_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.keep_lyrics, Some(true));
assert_eq!(request.is_instrumental, Some(false));
assert_eq!(request.is_loopable, Some(true));
assert_eq!(request.bpm, Some(120));
assert_eq!(request.musical_key, Some(CommonMusicalKey::AMinor));
assert_eq!(request.sample_rate_hz, Some(48_000));
assert_eq!(request.speed, Some(1.5));
assert_eq!(request.volume, Some(0.8));
assert_eq!(request.pitch, Some(-2.0));
}
#[test]
fn idempotency_token_is_generated_when_missing() {
let request = build_artcraft_omni_audio_request(base_builder(), CommonAudioModelEnum::SunoMusic)
.expect("build");
assert!(request.idempotency_token.is_some());
assert!(!request.idempotency_token.unwrap().is_empty());
}
#[test]
fn audio_url_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn image_url_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("a song".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,8 @@
pub(crate) mod build_common;
pub(crate) mod request_common;
pub(crate) mod resolve;
pub mod seed_audio_1p0;
pub mod suno_music;
pub mod suno_remix;
pub mod suno_sample;
pub mod suno_sounds;
@@ -0,0 +1,29 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::audio::omni_gen_audio::omni_gen_audio_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
ArtcraftAudioResponsePayload, GenerateAudioResponse,
};
/// Send an audio generation request to the Artcraft omni-gen endpoint.
/// All Artcraft model request states delegate to this function.
pub async fn send_artcraft_omni_audio_request(
request: &OmniGenAudioCostAndGenerateRequest,
client: &RouterArtcraftClient,
) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let response = omni_gen_audio_generate(
&client.api_host,
Some(&client.credentials),
request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateAudioResponse::Artcraft(ArtcraftAudioResponsePayload {
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
@@ -0,0 +1,34 @@
//! Shared resolve helpers for the Artcraft audio provider.
//!
//! Artcraft only accepts media file tokens — raw URLs are rejected.
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
pub(super) fn resolve_audio_list_ref(
audio_list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
pub fn build_artcraft_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SeedAudio1p0)?;
let state = ArtcraftSeedAudio1p0RequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSeedAudio1p0(state)))
}
@@ -0,0 +1,51 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::seed_audio_1p0::request::ArtcraftSeedAudio1p0RequestState;
const COST_IN_USD_CENTS: u64 = 21;
/// Seed Audio 1.0 via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedAudio1p0CostState;
impl ArtcraftSeedAudio1p0CostState {
pub fn from_request(_request: &ArtcraftSeedAudio1p0RequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_twenty_one_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Artcraft,
prompt: Some("a suspense radio drama".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(21));
assert_eq!(estimate.cost_in_credits, Some(21));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSeedAudio1p0CostState.estimate_cost().cost_in_usd_cents, Some(21));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,78 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedAudio1p0RequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSeedAudio1p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_speech_drama() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Artcraft,
prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
pub fn build_artcraft_suno_music(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoMusic)?;
let state = ArtcraftSunoMusicRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoMusic(state)))
}
@@ -0,0 +1,51 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_music::request::ArtcraftSunoMusicRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Music via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoMusicCostState;
impl ArtcraftSunoMusicCostState {
pub fn from_request(_request: &ArtcraftSunoMusicRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("a song".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoMusicCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,79 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoMusicRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoMusicRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_music() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Artcraft,
prompt: Some("A song about a corgi who learns to sail the open sea".to_string()),
style_prompt: Some("Sea shanty, folk".to_string()),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
pub fn build_artcraft_suno_remix(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoRemix)?;
let state = ArtcraftSunoRemixRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoRemix(state)))
}
@@ -0,0 +1,57 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_remix::request::ArtcraftSunoRemixRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Remix via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoRemixCostState;
impl ArtcraftSunoRemixCostState {
pub fn from_request(_request: &ArtcraftSunoRemixRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Artcraft,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoRemixCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,88 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoRemixRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoRemixRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
/// Replace with a real uploaded audio media token before running.
/// (test_data doesn't have a production audio media token yet.)
const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token";
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn remix_uploaded_audio() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Artcraft,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()),
])),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
pub fn build_artcraft_suno_sample(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSample)?;
let state = ArtcraftSunoSampleRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSample(state)))
}
@@ -0,0 +1,57 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_sample::request::ArtcraftSunoSampleRequestState;
const COST_IN_USD_CENTS: u64 = 8;
/// Suno Sample via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSampleCostState;
impl ArtcraftSunoSampleCostState {
pub fn from_request(_request: &ArtcraftSunoSampleRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_eight_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Artcraft,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(8));
assert_eq!(estimate.cost_in_credits, Some(8));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoSampleCostState.estimate_cost().cost_in_usd_cents, Some(8));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,89 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSampleRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoSampleRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
/// Replace with a real uploaded audio media token before running.
/// (test_data doesn't have a production audio media token yet.)
const AUDIO_MEDIA_TOKEN: &str = "m_replace_with_uploaded_audio_token";
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn sample_uploaded_audio() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Artcraft,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new(AUDIO_MEDIA_TOKEN.to_string()),
])),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_audio_model::CommonAudioModel as CommonAudioModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::artcraft::build_common::build_artcraft_omni_audio_request;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
pub fn build_artcraft_suno_sounds(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_audio_request(builder, CommonAudioModelEnum::SunoSounds)?;
let state = ArtcraftSunoSoundsRequestState { request };
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::ArtcraftSunoSounds(state)))
}
@@ -0,0 +1,51 @@
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::artcraft::suno_sounds::request::ArtcraftSunoSoundsRequestState;
const COST_IN_USD_CENTS: u64 = 3;
/// Suno Sounds via Artcraft is flat priced, so the cost state carries no
/// request fields.
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSoundsCostState;
impl ArtcraftSunoSoundsCostState {
pub fn from_request(_request: &ArtcraftSunoSoundsRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use super::*;
#[test]
fn flat_cost_is_three_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Artcraft,
prompt: Some("rain sound effects".to_string()),
..Default::default()
};
let estimate = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(estimate.cost_in_usd_cents, Some(3));
assert_eq!(estimate.cost_in_credits, Some(3));
// Direct cost-state path matches the builder path.
assert_eq!(ArtcraftSunoSoundsCostState.estimate_cost().cost_in_usd_cents, Some(3));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,82 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
use crate::generate::generate_audio::providers::artcraft::request_common::send_artcraft_omni_audio_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSunoSoundsRequestState {
pub request: OmniGenAudioCostAndGenerateRequest,
}
impl ArtcraftSunoSoundsRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
send_artcraft_omni_audio_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation::common_musical_key::CommonMusicalKey;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn loopable_sound_effect() {
let response = run_pipeline(GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Artcraft,
prompt: Some("Lo-fi vinyl crackle groove".to_string()),
is_loopable: Some(true),
bpm: Some(85),
musical_key: Some(CommonMusicalKey::AMinor),
..Default::default()
}).await;
assert!(matches!(response, GenerateAudioResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1 @@
pub mod seed_audio_1p0;
@@ -0,0 +1,588 @@
use fal_client::requests::api::audio::omni::seed_audio_1p0::api::{
SeedAudio1p0OutputFormat, SeedAudio1p0Request, SeedAudio1p0SampleRate,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
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_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
use crate::generate::generate_audio::providers::reject_unsupported::reject_unsupported_option;
const MAX_AUDIO_REFERENCES: usize = 3;
const MIN_SPEED: f64 = 0.5;
const MAX_SPEED: f64 = 2.0;
const MIN_VOLUME: f64 = 0.5;
const MAX_VOLUME: f64 = 2.0;
const MIN_PITCH: i8 = -12;
const MAX_PITCH: i8 = 12;
pub fn build_fal_seed_audio_1p0(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_seed_audio_1p0_state(builder)?;
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::FalSeedAudio1p0(state)))
}
pub(crate) fn build_fal_seed_audio_1p0_state(
mut builder: GenerateAudioRequestBuilder,
) -> Result<FalSeedAudio1p0RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Seed Audio has no equivalent for.
reject_unsupported_option("style_prompt", builder.style_prompt.as_ref(), strategy)?;
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Seed Audio 1.0".to_string())
})?;
let audio_urls = plan_audio_urls(builder.audio_references.take(), strategy)?;
let image_url = plan_image_url(builder.image_references.take(), strategy)?;
// fal rejects requests that combine an image reference with audio
// references, so the router always rejects the combination too.
if audio_urls.is_some() && image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_references",
value: "Seed Audio 1.0 cannot combine an image reference with audio references".to_string(),
}));
}
let request = SeedAudio1p0Request {
prompt,
// The omni audio API doesn't expose voice selection yet, so the model
// always picks the voice.
voice: None,
audio_urls,
image_url,
output_format: Some(SeedAudio1p0OutputFormat::Mp3),
sample_rate: plan_sample_rate(builder.sample_rate_hz, strategy)?,
speed: plan_multiplier("speed", builder.speed, MIN_SPEED, MAX_SPEED, strategy)?,
volume: plan_multiplier("volume", builder.volume, MIN_VOLUME, MAX_VOLUME, strategy)?,
pitch: plan_pitch(builder.pitch, strategy)?,
};
Ok(FalSeedAudio1p0RequestState { request })
}
// ── Plan helpers ──
/// Seed Audio takes up to 3 reference audio URLs. Fal only accepts URLs (not
/// media tokens). More than 3 references reject under `ErrorOut`; the other
/// strategies keep the first 3.
fn plan_audio_urls(
audio_references: Option<AudioListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let mut urls = match audio_references {
None => return Ok(None),
Some(AudioListRef::Urls(urls)) => urls,
Some(AudioListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(AudioListRef::MediaFileTokens(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
};
if urls.is_empty() {
return Ok(None);
}
if urls.len() > MAX_AUDIO_REFERENCES {
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "audio_references",
value: format!(
"Seed Audio 1.0 supports at most {} audio references, got {}",
MAX_AUDIO_REFERENCES,
urls.len(),
),
}));
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
urls.truncate(MAX_AUDIO_REFERENCES);
}
}
}
Ok(Some(urls))
}
/// Seed Audio takes a single reference image URL. Fal only accepts URLs (not
/// media tokens). More than 1 reference rejects under `ErrorOut`; the other
/// strategies keep the first.
fn plan_image_url(
image_references: Option<ImageListRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<String>, ArtcraftRouterError> {
let urls = match image_references {
None => return Ok(None),
Some(ImageListRef::Urls(urls)) => urls,
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(ImageListRef::MediaFileTokens(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
};
if urls.is_empty() {
return Ok(None);
}
if urls.len() > 1 {
if let RequestMismatchMitigationStrategy::ErrorOut = strategy {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_references",
value: format!("Seed Audio 1.0 supports at most 1 image reference, got {}", urls.len()),
}));
}
}
Ok(urls.into_iter().next())
}
/// Map a requested sample rate to Seed Audio's supported rates. An exact
/// match is required under `ErrorOut`; the other strategies pick the nearest
/// supported rate (ties resolve to the lower rate).
fn plan_sample_rate(
sample_rate_hz: Option<u32>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<SeedAudio1p0SampleRate>, ArtcraftRouterError> {
const SUPPORTED: [(u32, SeedAudio1p0SampleRate); 6] = [
(8_000, SeedAudio1p0SampleRate::Hz8000),
(16_000, SeedAudio1p0SampleRate::Hz16000),
(24_000, SeedAudio1p0SampleRate::Hz24000),
(32_000, SeedAudio1p0SampleRate::Hz32000),
(44_100, SeedAudio1p0SampleRate::Hz44100),
(48_000, SeedAudio1p0SampleRate::Hz48000),
];
let Some(requested) = sample_rate_hz else {
return Ok(None);
};
if let Some((_, rate)) = SUPPORTED.iter().find(|(hz, _)| *hz == requested) {
return Ok(Some(*rate));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "sample_rate_hz",
value: format!("{}", requested),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
let (_, nearest) = SUPPORTED.iter()
.min_by_key(|(hz, _)| requested.abs_diff(*hz))
.expect("SUPPORTED is non-empty");
Ok(Some(*nearest))
}
}
}
/// Clamp a 0.5–2.0 multiplier (speed / volume) per strategy: out-of-range
/// values reject under `ErrorOut` and clamp otherwise.
fn plan_multiplier(
field: &'static str,
value: Option<f64>,
min: f64,
max: f64,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<f64>, ArtcraftRouterError> {
let Some(value) = value else {
return Ok(None);
};
if value >= min && value <= max {
return Ok(Some(value));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: format!("{}", value),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(value.clamp(min, max))),
}
}
/// Pitch is a semitone shift: round to the nearest whole semitone, then
/// reject (under `ErrorOut`) or clamp values outside ±12.
fn plan_pitch(
pitch: Option<f64>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<i8>, ArtcraftRouterError> {
let Some(pitch) = pitch else {
return Ok(None);
};
let rounded = pitch.round();
if rounded >= MIN_PITCH as f64 && rounded <= MAX_PITCH as f64 {
return Ok(Some(rounded as i8));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "pitch",
value: format!("{}", pitch),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(rounded.clamp(MIN_PITCH as f64, MAX_PITCH as f64) as i8))
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use super::*;
const AUDIO_URL: &str = "https://example.com/a.mp3";
const IMAGE_URL: &str = "https://example.com/scene.png";
mod basics {
use super::*;
#[test]
fn prompt_is_passed_through() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert_eq!(state.request.prompt, "a suspense radio drama");
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn voice_is_always_none() {
// The omni audio API doesn't expose voice selection.
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert!(state.request.voice.is_none());
}
#[test]
fn output_format_is_mp3() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert_eq!(state.request.output_format, Some(SeedAudio1p0OutputFormat::Mp3));
}
}
mod reference_tests {
use super::*;
#[test]
fn audio_urls_pass_through() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls, Some(vec![AUDIO_URL.to_string()]));
}
#[test]
fn three_audio_urls_are_accepted() {
let builder = builder_with_audio_urls(3);
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3));
}
#[test]
fn four_audio_urls_error_out() {
let mut builder = builder_with_audio_urls(4);
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn four_audio_urls_truncate_under_lenient_strategies() {
let mut builder = builder_with_audio_urls(4);
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.audio_urls.as_ref().map(|u| u.len()), Some(3));
}
#[test]
fn audio_media_tokens_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..base_builder()
};
assert!(matches!(
build_fal_seed_audio_1p0_state(builder),
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn image_url_passes_through() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.image_url.as_deref(), Some(IMAGE_URL));
}
#[test]
fn two_image_urls_error_out() {
let builder = GenerateAudioRequestBuilder {
image_references: Some(ImageListRef::Urls(vec![
IMAGE_URL.to_string(),
"https://example.com/other.png".to_string(),
])),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn combining_image_and_audio_references_is_always_rejected() {
// fal rejects the combination, so the router rejects it under every
// mitigation strategy.
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![AUDIO_URL.to_string()])),
image_references: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert!(
build_fal_seed_audio_1p0_state(builder).is_err(),
"expected rejection under {strategy:?}",
);
}
}
}
mod sample_rate_tests {
use super::*;
#[test]
fn exact_rates_map_directly() {
let cases = [
(8_000, SeedAudio1p0SampleRate::Hz8000),
(16_000, SeedAudio1p0SampleRate::Hz16000),
(24_000, SeedAudio1p0SampleRate::Hz24000),
(32_000, SeedAudio1p0SampleRate::Hz32000),
(44_100, SeedAudio1p0SampleRate::Hz44100),
(48_000, SeedAudio1p0SampleRate::Hz48000),
];
for (hz, expected) in cases {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(hz),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz");
}
}
#[test]
fn inexact_rate_errors_out_under_error_out() {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(22_050),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn inexact_rate_maps_to_nearest_under_lenient_strategies() {
let cases = [
(7_000, SeedAudio1p0SampleRate::Hz8000),
(22_050, SeedAudio1p0SampleRate::Hz24000),
(40_000, SeedAudio1p0SampleRate::Hz44100),
(96_000, SeedAudio1p0SampleRate::Hz48000),
];
for (hz, expected) in cases {
let builder = GenerateAudioRequestBuilder {
sample_rate_hz: Some(hz),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.sample_rate, Some(expected), "for {hz} Hz");
}
}
#[test]
fn no_rate_stays_none() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
assert!(state.request.sample_rate.is_none());
}
}
mod speed_and_volume_tests {
use super::*;
#[test]
fn in_range_values_pass_through() {
let builder = GenerateAudioRequestBuilder {
speed: Some(1.5),
volume: Some(0.5),
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.speed, Some(1.5));
assert_eq!(state.request.volume, Some(0.5));
}
#[test]
fn out_of_range_speed_errors_out() {
let builder = GenerateAudioRequestBuilder {
speed: Some(3.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
#[test]
fn out_of_range_speed_clamps_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
speed: Some(3.0),
volume: Some(0.1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.speed, Some(2.0));
assert_eq!(state.request.volume, Some(0.5));
}
}
mod pitch_tests {
use super::*;
#[test]
fn pitch_rounds_to_nearest_semitone() {
let cases = [
(2.4, 2i8),
(2.5, 3i8),
(-2.4, -2i8),
(-2.5, -3i8),
(0.0, 0i8),
];
for (input, expected) in cases {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected), "for pitch {input}");
}
}
#[test]
fn out_of_range_pitch_errors_out() {
for pitch in [13.0, -13.0] {
let builder = GenerateAudioRequestBuilder {
pitch: Some(pitch),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_err(), "for pitch {pitch}");
}
}
#[test]
fn out_of_range_pitch_clamps_under_lenient_strategies() {
let cases = [(20.0, 12i8), (-20.0, -12i8)];
for (input, expected) in cases {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected), "for pitch {input}");
}
}
#[test]
fn boundary_pitch_is_accepted() {
for (input, expected) in [(12.0, 12i8), (-12.0, -12i8)] {
let builder = GenerateAudioRequestBuilder {
pitch: Some(input),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let state = build_fal_seed_audio_1p0_state(builder).expect("build");
assert_eq!(state.request.pitch, Some(expected));
}
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
use enums::common::generation::common_musical_key::CommonMusicalKey;
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { style_prompt: Some("EDM".to_string()), ..base_builder() },
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { musical_key: Some(CommonMusicalKey::CMajor), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_seed_audio_1p0_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
style_prompt: Some("EDM".to_string()),
keep_lyrics: Some(true),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_fal_seed_audio_1p0_state(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
prompt: Some("a suspense radio drama".to_string()),
..Default::default()
}
}
fn builder_with_audio_urls(count: usize) -> GenerateAudioRequestBuilder {
let urls = (0..count).map(|i| format!("https://example.com/a{i}.mp3")).collect();
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(urls)),
..base_builder()
}
}
}
@@ -0,0 +1,78 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::request::FalSeedAudio1p0RequestState;
#[derive(Clone, Debug)]
pub struct FalSeedAudio1p0CostState {
pub cost_in_usd_cents: u64,
}
impl FalSeedAudio1p0CostState {
pub fn from_request(request: &FalSeedAudio1p0RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
AudioGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::fal::seed_audio_1p0::build::build_fal_seed_audio_1p0_state;
use super::*;
#[test]
fn estimate_is_nineteen_cents() {
let state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
let estimate = FalSeedAudio1p0CostState::from_request(&state).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(19));
assert_eq!(estimate.cost_in_credits, Some(19));
}
#[test]
fn options_do_not_change_the_estimate() {
let tuned = GenerateAudioRequestBuilder {
sample_rate_hz: Some(48_000),
speed: Some(2.0),
volume: Some(0.5),
pitch: Some(-12.0),
..base_builder()
};
let base_state = build_fal_seed_audio_1p0_state(base_builder()).expect("build");
let tuned_state = build_fal_seed_audio_1p0_state(tuned).expect("build");
assert_eq!(
FalSeedAudio1p0CostState::from_request(&base_state).cost_in_usd_cents,
FalSeedAudio1p0CostState::from_request(&tuned_state).cost_in_usd_cents,
);
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
prompt: Some("a calm narrator describes ocean waves".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,118 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::audio::omni::seed_audio_1p0::api::SeedAudio1p0Request;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::generate_audio_response::{
FalAudioResponsePayload, GenerateAudioResponse,
};
#[derive(Clone, Debug)]
pub struct FalSeedAudio1p0RequestState {
/// Final materialized request; ready to fire.
pub request: SeedAudio1p0Request,
}
impl FalSeedAudio1p0RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let payload = if let Some(webhook_url) = &client.webhook_url {
let response = self.request.send_webhook_request(&client.api_key, webhook_url).await?;
FalAudioResponsePayload {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound),
}
} else {
let response = self.request.send_queue_request(&client.api_key).await?;
FalAudioResponsePayload {
request_id: Some(response.request_id),
gateway_request_id: None,
maybe_status_url: Some(response.status_url),
maybe_response_url: Some(response.response_url),
maybe_outbound_request: Some(outbound),
}
};
Ok(GenerateAudioResponse::Fal(payload))
}
}
#[cfg(test)]
mod tests {
use fal_client::creds::fal_api_key::FalApiKey;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_seed_audio_webhook() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string()),
..seed_audio_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_seed_audio_tuned() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("Welcome aboard the midnight express. Please keep your arms inside the train.".to_string()),
sample_rate_hz: Some(44_100),
speed: Some(0.9),
volume: Some(1.2),
pitch: Some(-2.0),
..seed_audio_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn seed_audio_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SeedAudio1p0,
provider: RouterProvider::Fal,
..Default::default()
}
}
fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Seed Audio skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
response
}
}
@@ -0,0 +1,5 @@
pub mod suno_music;
pub mod suno_remix;
pub mod suno_sample;
pub mod suno_sounds;
pub(crate) mod resolve;
@@ -0,0 +1,133 @@
//! Shared resolve helpers for the Kinovi audio provider.
//!
//! The remix/sample models take exactly one user audio reference, which must
//! be downloaded from our CDN and re-uploaded to the Kinovi CDN during the
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
//! video provider (`generate_video::providers::kinovi::upload`).
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
/// A single unresolved audio reference: either an ArtCraft/public URL or a
/// media file token that resolves to one via the draft context's map.
#[derive(Debug, Clone)]
pub enum SingleAudioRef {
Url(String),
MediaFileToken(MediaFileToken),
}
/// Extract exactly one audio reference from the builder's audio list.
/// The Kinovi remix/sample models operate on a single source track, so zero
/// or multiple references are rejected regardless of mitigation strategy.
pub(crate) fn require_single_audio_ref(
audio_references: Option<AudioListRef>,
) -> Result<SingleAudioRef, ArtcraftRouterError> {
let refs = match audio_references {
None => {
return Err(ArtcraftRouterError::InvalidInput(
"Exactly one audio reference is required, but none was provided".to_string(),
));
}
Some(AudioListRef::Urls(urls)) => urls.into_iter().map(SingleAudioRef::Url).collect::<Vec<_>>(),
Some(AudioListRef::MediaFileTokens(tokens)) => {
tokens.into_iter().map(SingleAudioRef::MediaFileToken).collect::<Vec<_>>()
}
};
match refs.len() {
0 => Err(ArtcraftRouterError::InvalidInput(
"Exactly one audio reference is required, but none was provided".to_string(),
)),
1 => Ok(refs.into_iter().next().expect("length checked above")),
count => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "audio_references",
value: format!("Exactly one audio reference is required, got {}", count),
})),
}
}
/// Resolve a single audio reference to a source URL and re-upload it to the
/// Kinovi CDN, returning the Kinovi CDN URL.
pub(crate) async fn resolve_and_upload_audio_ref(
session: &Seedance2ProSession,
audio_ref: &SingleAudioRef,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<String, ArtcraftRouterError> {
let source_url = match audio_ref {
SingleAudioRef::Url(url) => url.clone(),
SingleAudioRef::MediaFileToken(token) => {
let map = draft_context.media_file_to_artcraft_url_map
.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
token: token.clone(),
})
})?
}
};
upload_to_seedance2pro(session, &source_url).await
}
#[cfg(test)]
mod tests {
use super::*;
mod require_single_audio_ref_tests {
use super::*;
#[test]
fn none_is_rejected() {
assert!(require_single_audio_ref(None).is_err());
}
#[test]
fn empty_url_list_is_rejected() {
assert!(require_single_audio_ref(Some(AudioListRef::Urls(vec![]))).is_err());
}
#[test]
fn empty_token_list_is_rejected() {
assert!(require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![]))).is_err());
}
#[test]
fn single_url_is_accepted() {
let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
]))).expect("should accept a single URL");
assert!(matches!(result, SingleAudioRef::Url(url) if url == "https://example.com/a.mp3"));
}
#[test]
fn single_token_is_accepted() {
let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
]))).expect("should accept a single token");
assert!(matches!(result, SingleAudioRef::MediaFileToken(t) if t.as_str() == "mf_test123"));
}
#[test]
fn two_urls_are_rejected() {
let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
"https://example.com/b.mp3".to_string(),
])));
assert!(result.is_err());
}
#[test]
fn two_tokens_are_rejected() {
let result = require_single_audio_ref(Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
])));
assert!(result.is_err());
}
}
}
@@ -0,0 +1,128 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::audio_generation_request::AudioGenerationRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_audio_references, reject_unsupported_image_references,
reject_unsupported_option,
};
pub fn build_kinovi_suno_music(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_kinovi_suno_music_state(builder)?;
Ok(AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(state)))
}
pub(crate) fn build_kinovi_suno_music_state(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoMusicRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Music has no equivalent for.
reject_unsupported_option("keep_lyrics", builder.keep_lyrics.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_audio_references(builder.audio_references.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Music".to_string())
})?;
let request = GenerateSunoMusicRequest {
prompt,
style_tags: builder.style_prompt.take(),
instrumental: builder.is_instrumental.unwrap_or(false),
};
Ok(KinoviSunoMusicRequestState { request })
}
#[cfg(test)]
mod tests {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
#[test]
fn prompt_and_style_are_passed_through() {
let state = build_kinovi_suno_music_state(base_builder()).expect("build");
assert_eq!(state.request.prompt, "a song about corgis");
assert_eq!(state.request.style_tags.as_deref(), Some("sea shanty"));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_music_state(builder).is_err());
}
#[test]
fn instrumental_defaults_to_false() {
let state = build_kinovi_suno_music_state(base_builder()).expect("build");
assert!(!state.request.instrumental);
}
#[test]
fn instrumental_passes_through() {
let builder = GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() };
let state = build_kinovi_suno_music_state(builder).expect("build");
assert!(state.request.instrumental);
}
#[test]
fn unsupported_options_error_out() {
let error_out = RequestMismatchMitigationStrategy::ErrorOut;
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..base_builder()
},
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = error_out;
assert!(build_kinovi_suno_music_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
keep_lyrics: Some(true),
bpm: Some(120),
speed: Some(1.5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_kinovi_suno_music_state(builder).is_ok());
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song about corgis".to_string()),
style_prompt: Some("sea shanty".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,65 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
/// Suno Music is flat priced, so the cost state carries no request fields.
#[derive(Clone, Debug)]
pub struct KinoviSunoMusicCostState;
impl KinoviSunoMusicCostState {
pub fn from_request(_request: &KinoviSunoMusicRequestState) -> Self {
Self
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoMusicRequest {
prompt: String::new(),
style_tags: None,
instrumental: false,
};
let costs = pricing_request.calculate_costs();
AudioGenerationCostEstimate {
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 crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::suno_music::build::build_kinovi_suno_music_state;
use super::*;
#[test]
fn flat_cost_is_seven_cents() {
let estimate = KinoviSunoMusicCostState.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
assert_eq!(estimate.cost_in_credits, Some(16));
}
#[test]
fn cost_from_built_request_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song".to_string()),
..Default::default()
};
let state = build_kinovi_suno_music_state(builder).expect("build");
let estimate = KinoviSunoMusicCostState::from_request(&state).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(7));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,113 @@
use seedance2pro_client::generate::audio::generate_suno_music::{
generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSunoMusicRequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSunoMusicRequest,
}
impl KinoviSunoMusicRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoMusicArgs {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_suno_music(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn music_with_vocals() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("A song about a corgi who learns to sail the open sea".to_string()),
style_prompt: Some("Sea shanty, folk".to_string()),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn music_instrumental() {
let response = run_pipeline(GenerateAudioRequestBuilder {
prompt: Some("An epic journey across a frozen mountain range".to_string()),
style_prompt: Some("Cinematic orchestral score".to_string()),
is_instrumental: Some(true),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn suno_music_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
..Default::default()
}
}
fn get_seedance2pro_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))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
AudioGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Suno Music skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,178 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft::AudioGenerationDraftRequest;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::providers::kinovi::resolve::require_single_audio_ref;
use crate::generate::generate_audio::providers::kinovi::suno_remix::draft::KinoviSunoRemixDraftState;
use crate::generate::generate_audio::providers::reject_unsupported::{
reject_unsupported_image_references, reject_unsupported_option,
};
pub fn build_kinovi_suno_remix(builder: GenerateAudioRequestBuilder) -> Result<AudioGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = build_kinovi_suno_remix_draft(builder)?;
Ok(AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(draft)))
}
pub(crate) fn build_kinovi_suno_remix_draft(
mut builder: GenerateAudioRequestBuilder,
) -> Result<KinoviSunoRemixDraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Options Suno Remix has no equivalent for.
reject_unsupported_option("is_instrumental", builder.is_instrumental.as_ref(), strategy)?;
reject_unsupported_option("is_loopable", builder.is_loopable.as_ref(), strategy)?;
reject_unsupported_option("bpm", builder.bpm.as_ref(), strategy)?;
reject_unsupported_option("musical_key", builder.musical_key.as_ref(), strategy)?;
reject_unsupported_option("sample_rate_hz", builder.sample_rate_hz.as_ref(), strategy)?;
reject_unsupported_option("speed", builder.speed.as_ref(), strategy)?;
reject_unsupported_option("volume", builder.volume.as_ref(), strategy)?;
reject_unsupported_option("pitch", builder.pitch.as_ref(), strategy)?;
reject_unsupported_image_references(builder.image_references.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput("A prompt is required for Suno Remix".to_string())
})?;
let audio_source = require_single_audio_ref(builder.audio_references.take())?;
Ok(KinoviSunoRemixDraftState {
prompt,
style_tags: builder.style_prompt.take(),
keep_lyrics: builder.keep_lyrics.unwrap_or(false),
audio_source: Some(audio_source),
})
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_audio::providers::kinovi::resolve::SingleAudioRef;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_and_style_are_passed_through() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert_eq!(draft.prompt, "make this electronic");
assert_eq!(draft.style_tags.as_deref(), Some("EDM style"));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateAudioRequestBuilder { prompt: None, ..base_builder() };
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
#[test]
fn keep_lyrics_defaults_to_false() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert!(!draft.keep_lyrics);
}
#[test]
fn keep_lyrics_passes_through() {
let builder = GenerateAudioRequestBuilder { keep_lyrics: Some(true), ..base_builder() };
let draft = build_kinovi_suno_remix_draft(builder).expect("build");
assert!(draft.keep_lyrics);
}
}
mod audio_reference_requirements {
use super::*;
#[test]
fn exactly_one_url_reference_is_accepted() {
let draft = build_kinovi_suno_remix_draft(base_builder()).expect("build");
assert!(matches!(
draft.audio_source,
Some(SingleAudioRef::Url(url)) if url == "https://example.com/a.mp3"
));
}
#[test]
fn a_media_token_reference_is_accepted() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..base_builder()
};
let draft = build_kinovi_suno_remix_draft(builder).expect("build");
assert!(matches!(
draft.audio_source,
Some(SingleAudioRef::MediaFileToken(t)) if t.as_str() == "mf_test123"
));
}
#[test]
fn zero_references_are_rejected() {
let builder = GenerateAudioRequestBuilder { audio_references: None, ..base_builder() };
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
#[test]
fn two_references_are_rejected() {
let builder = GenerateAudioRequestBuilder {
audio_references: Some(AudioListRef::Urls(vec![
"https://example.com/a.mp3".to_string(),
"https://example.com/b.mp3".to_string(),
])),
..base_builder()
};
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateAudioRequestBuilder> = vec![
GenerateAudioRequestBuilder { is_instrumental: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { is_loopable: Some(true), ..base_builder() },
GenerateAudioRequestBuilder { bpm: Some(120), ..base_builder() },
GenerateAudioRequestBuilder { sample_rate_hz: Some(48_000), ..base_builder() },
GenerateAudioRequestBuilder { speed: Some(1.5), ..base_builder() },
GenerateAudioRequestBuilder { volume: Some(0.5), ..base_builder() },
GenerateAudioRequestBuilder { pitch: Some(2.0), ..base_builder() },
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_kinovi_suno_remix_draft(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateAudioRequestBuilder {
is_instrumental: Some(true),
bpm: Some(120),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_kinovi_suno_remix_draft(builder).is_ok());
}
}
// ── Helpers ──
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
prompt: Some("make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
}
}
}

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