Compare commits

..
Author SHA1 Message Date
Tanish Manku e9ea2a938c Scene Builder: T-pose in clip gaps + drag-preview card for animations
- Gaps show the default pose: a disabled three.js action leaves the skeleton
  frozen on its last frame, so evaluateAt now resets any character with no clip
  under the playhead to its bind (T) pose via Skeleton.pose() (skeletons cached
  per character). Fixes blank space after a clip holding the clip's last pose.
- Animation drags now show the shared DragGhost tilt card with the clip
  thumbnail: the drawer's native-DnD handlers drive dragItem/dragPosition/
  assetDraggingUnder and suppress the browser's default drag image, matching
  object/character pickup motion.
2026-07-09 00:59:24 -07:00
Tanish Manku dc5608613d webapp: stop dev API host override pointing at localhost:12345
The DEV block set the API host to window.location.origin (so /v1 calls hit the
Vite proxy → api.storyteller.ai) and then immediately called setDevelopment(),
which overrode it to http://localhost:12345 — a local backend that isn't
running. Every media-token resolution failed, so getMediaUrlByToken returned ''
and GLB fetches fell back to localhost, serving nothing. The inline comment
already described this line as disabled; it just wasn't commented out. Now it
is, so local dev resolves media through the proxy as intended.
2026-07-09 00:59:24 -07:00
Tanish Manku 1a90bd54c2 Scene Builder: one clip row per character + overlap guard
Collapse the per-character clips onto a single timeline row (playback is
sequential — one clip at a time), replacing stacked lanes. Stacking only earned
its keep for additive layering, which is out of scope.

- TimelineController: add/move route through resolveFreeStart (snap to nearest
  non-overlapping slot); resizeClipLane and resolveClipDuration clamp against
  nextStartAfter so a strip never overruns the following clip. A character's
  strips stay a non-overlapping sequence, so evaluateAt has at most one active
  clip at any playhead.
- TimelineClipRow: renders all of a character's strips on one shared row
  (shown even when empty, as a drop hint); per-strip move/trim/loop and an
  inline x to remove.
- TimelineEditor: one TimelineClipRow per character instead of one per lane.
2026-07-08 00:04:13 -07:00
Tanish Manku 2920a189f0 Scene Builder: Mixamo clip-lane timeline UI + real clip length (phase C)
Stacked clip-strip rendering on the timeline with move/trim/loop/delete, drag-
and-drop from the Animations drawer onto a character's lane, and backfill of the
clip's real length into the strip once its GLB loads.

- TimelineClipRow: one stacked strip per clip lane under its character's
  keyframe row; body drags to move, right edge trims length, loop chip toggles
  repeat, trash removes. Clip edits ride the timeline Save/Cancel session (no
  per-op undo, matching keyframes).
- TimelineEditor: each character row is a drop target for clip drags; drop time
  is read from the ruler rect. Non-character rows ignore clip drags.
- AnimationsDrawer clips are now draggable (ANIMATION_CLIP_MIME) as well as
  click-to-add.
- ClipStrip.autoDuration: a fresh drop seeds a placeholder width, then
  CharacterAnimationManager.resolveClipDuration adopts the loaded clip's true
  length (re-clamping start); a user trim clears the flag so reload never
  clobbers a hand-set duration.
- clipBindsToCharacter warns when a clip resolves 0 tracks on the character —
  the diagnostic for whether retargeting is needed.
2026-07-07 23:51:37 -07:00
Tanish Manku 7f5e82cbd9 Scene Builder: Mixamo clip playback engine + drawer (phase B, spike)
Deterministic, playhead-driven skeletal-animation playback for character clip
lanes, plus a minimal click-to-add drawer so retargeting/binding can be verified
in the viewport before the full drag-drop / stacked-strip UI is built.

- CharacterAnimationManager: one AnimationMixer per character; each clip lane is
  an AnimationAction posed at an absolute time from the playhead + mixer.update(0)
  (scrub / play / record all pose identically). syncClipLanes() reconciles the
  runtime on every lane change; a load token guards superseded async clip loads.
- Scene.loadRawGlb(media_id): raw GLTF (scene + animations) with no scene-add /
  placeholder — sources clips for binding. Clips bind by node name (shared
  mixamorig:* naming); SkeletonUtils.retargetClip is the documented fallback.
- TimelineController: getPlayhead(); evaluate() poses characters; sync on
  add/move/remove clip lane + load/cancel/clear.
- actions: addClipToCharacter / removeClipLane.
- comps/AnimationsDrawer: right-docked, gated on a selected character; click a
  curated clip to drop it as a lane at the playhead.

Compiles clean (tsc exit 0); playback is NOT yet runtime-verified.
2026-07-07 15:42:24 -07:00
Tanish Manku 5c2fbbc9f3 Scene Builder: animation clip-lane data model (Mixamo drawer, phase A)
Foundation for placing Mixamo skeletal-animation clips on the timeline as
stacked lanes under character objects, alongside the existing keyframe tracks.

- ClipStrip / ClipLane types; TimelineData gains a `clipLanes` array (only the
  clip reference + placement is serialized — the AnimationClip is resolved and
  retargeted from sourceMediaId at runtime).
- TimelineController: addClipLane / moveClipLane / removeClipLane / clipLanesFor,
  create()/loadTimeline() init + backfill clipLanes, emitChanged carries them.
- TimelineChangedEvent → store mirror (timelineClipLanes) via EngineStoreBridge.
- Persistence rides the existing timeline save/load (getTimeline/loadTimeline).
2026-07-07 15:28:50 -07:00
Tanish Manku 11c7aa979d Scene Builder fixes: record-mode camera lock, Blender default framing, auto-keyframe
- Record mode is now fully immutable: gate MouseControls mouse-look + focus()
  on the viewport lock (new MouseControlsDeps.isViewportLocked → cameraController
  .locked), matching the existing FreeCam/tick gates.
- Default main (viewport) camera pulled back + elevated to a Blender-style 3/4
  view so a blank scene shows the full render-camera frustum.
- Auto-keyframe: mutating an already-keyframed object (gizmo drag-end or panel
  commit) replaces/creates a keyframe at the playhead via
  TimelineController.autoKeyIfTracked.
- loadTimeline backfills fps/duration/tracks defensively (legacy save safety).
2026-07-07 15:23:50 -07:00
bflatastic 26aa3d3839 3D scene editor: exact camera framing, timeline UX, outliner + chrome redesign
Camera framing
- Replace the full-canvas Letterbox with CameraFrame: an inset, aspect-correct
  frame (dimmed surround, rule-of-thirds, focal label) shown only in camera
  view and record mode — the free viewport no longer shows mattes
- The frame shows the EXACT render framing: CameraController.setCameraFrameRect
  offsets the viewport projection via camera.setViewOffset; snapshots and the
  timeline recorder clear/reapply the offset so captures stay full-bleed
- recordTimeline now encodes at the render camera's resolution/aspect with the
  grid hidden (was grabbing the raw viewport canvas)
- Frame avoids the bottom UI; expanded-timeline height is measured live

Aspect ratio
- Default is now 16:9 via DEFAULT_CAMERA_ASPECT_RATIO (store, controller,
  scene reset, newScene); removed the model cold-sync that forced Square
- New top-right aspect popover (AspectRatioMenu rewritten on PopoverMenu with
  proportional AspectRatioIcon, now exported from ui-promptbox)

Timeline
- Expanded editor shows every scene object as a track row (no selection
  needed), capped at ~3.5 rows then scrollable (scrollbar hidden so lanes
  stay width-aligned with the ruler)
- Restyled per design: dark lanes, keyframe diamonds + connecting line,
  playhead diamond handle, select-none everywhere
- Scrubbing: drag the ruler to scrub smoothly, following the pointer 1:1;
  times quantize to the timeline's frame grid (30fps) — transport readout
  shows frame timecode (m:ss:ff); keyframe drags quantize the same way
- Fixed playhead/keyframe misalignment (scrollbar shrank lanes vs ruler;
  label indent must be padding, not margin, to keep the w-32 column)
- Delete/Backspace removes the selected keyframe (capture-phase, so the
  engine's delete-scene-object keybind doesn't fire); clicking away
  deselects it
- Motion (easing) popover opens from the curve chip BETWEEN keyframes (new
  timelineEasingKeyframeId store field), centered above the chip in lane
  coordinates, backdrop-blurred, dismissed on outside click; selecting a
  keyframe no longer opens it
- Camera-track playback/scrubbing is now visible in camera view: the
  timeline mirrors the animated ::CAM:: pose onto the viewport camera

Record mode
- Viewport camera fully locked: mouse-look blocked via isMovable(), right-drag
  pan and wheel zoom gated in useFreeCam (build mode unaffected)

Chrome
- Outliner restyled as the "Scene" panel (+ Add via shared openAssetModal,
  two-line rows with friendly kind labels, blue selected card), open by
  default, vertically centered, fixed 45vh height independent of aspect ratio
- Build/Record pill fully rounded (new TabSelector listClassName prop)
- CameraStatusPill moved to the top-right camera cluster, height-matched
  with the aspect popover
- ExitCameraView is a regular labeled button docked right of the timeline bar
2026-07-07 23:15:54 +07:00
Tanish Manku 06315490b7 Scene Builder launch re-scope: drop promptbox, lightbox handoff, editable timeline duration
- Remove SceneBuilderPromptBox (MCP not ready). The animation timeline is the
  sole build-mode bottom UI: collapsed bar by default, expanded editor via
  chevron; an empty timeline is auto-created on init; auto-expands on load when
  the scene has keyframes or an imported THREE animation.
- Editable max timeline duration: TimelineController.setDuration (clamp 1-60s,
  covers 5-30) + click-to-edit DurationLabel in the bar/ruler.
- Capture/Record output → review modal (CompletionModal) that appears FIRST
  with a local preview (offline-safe), then uploads (images auto, videos manual)
  with status; on success opens the app Lightbox on the token (all destinations
  for the kind), on failure shows Retry.
- Adapter: add openMediaLightbox(token, kind); keep uploadMedia; remove
  openImageInEditor/openVideoInEditor (Lightbox owns destination routing). Host
  renders <Lightbox> and resolves cdnUrl via MediaFilesApi.

Compile-verified (tsc -b clean for pagescene + changed webapp files).
2026-07-06 23:54:47 -07:00
Tanish Manku c15d8bd1ec Scene Builder checkpoint: camera view fixes, record/capture + completion modal, save/load + undo persistence
Builds on the prior Scene Builder commit. Compile-verified (tsc -b clean for
pagescene + changed webapp files); see frontend/libs/components/pagescene/CLAUDE.md.

- Camera view: fixed record→build engine-teardown wipe (PreviewEngineCamera /
  cam-view canvas must stay mounted — hide via CSS, never unmount); Esc +
  viewport double-click exit; double-click frustum to enter; robust enter/exit
  pose; frustum pick-proxy hitbox; CameraStatusPill (Viewport vs render cam).
- Build promptbox: dedicated SceneBuilderPromptBox (MCP stub) replaced
  PromptBox3D; buildMode/toggle removed.
- Record mode: Capture (still) + Record (timeline→mp4 via mediabunny),
  RenderOverlay progress, producedArtifact cache, CompletionModal
  (preview/playback + Delete/Upload/Edit) + adapter handoff to 2D/create-video/
  video-editor (lazy, data-URL image handoff; best-effort uploads).
- Persistence: timeline + render-camera transform in scene JSON (save/load +
  unmount cache); ::CAM:: recreated on load after cameras restore.
- Undo: timeline transactional (SaveTimelineAction); camera moves via TransformAction.
2026-07-06 23:26:18 -07:00
Tanish Manku 40ec291395 Scene Builder: mode pill, prompted mode, timeline, camera split, record mode
Adds the Scene Builder feature set to the pagescene 3D editor (+ the shared
ui-promptbox). All UI-level; compile-verified only (tsc -b), not yet runtime
tested — see frontend/libs/components/pagescene/CLAUDE.md for the full spec and
per-slice "unverified" list.

- Build/Record mode pill (SceneModePill, TabSelector) + store `sceneMode`.
- Build sub-modes: Manual (current) vs Prompted. Prompted prompt bar shows a
  textbox + add-animation-timeline + prompt history + Update (Update is a stub
  pending the MCP scene-descriptor backend). Toggle via ButtonIconSelect.
- Animation timeline (in-memory): TimelineController + keyframe model +
  cubic-bezier interpolation ticked in the render loop; collapsed bar +
  expanded multi-track editor + Motion easing popover (draggable bezier);
  Save is an undoable action, Cancel reverts.
- Camera split (single camera): render camera is a selectable wireframe-frustum
  placeholder ("::CAM::") in the scene; look-through camera view via
  double-click / outliner view-from-camera icon; ExitCameraView button.
- Record mode: read-only playback through the locked render camera; build
  chrome hidden; Capture (still) works, Record (video) is a stub.
2026-07-03 05:24:48 -07:00
911 changed files with 18493 additions and 49366 deletions
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO voice_clone_requests\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n maybe_user_token = ?,\n email_address = ?,\n discord_username = ?,\n\n is_for_private_use = ?,\n is_for_public_use = ?,\n\n is_for_studio = ?,\n is_for_twitch_tts = ?,\n is_for_api_use = ?,\n is_for_music = ?,\n is_for_games = ?,\n is_for_other = ?,\n optional_notes_on_use = ?,\n\n is_own_voice = ?,\n is_third_party_voice = ?,\n optional_notes_on_subject = ?,\n\n has_clean_audio_recordings = ?,\n has_good_microphone = ?,\n\n optional_questions = ?,\n optional_extra_comments = ?,\n\n ip_address_creation = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 22
},
"nullable": []
},
"hash": "4cea10944b8869419ecdfea30c58f49a87c090b9083078043177c33737ecb949"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n COUNT(distinct token) as record_count,\n NOW() as present_time\nFROM\n(\n SELECT token\n FROM tts_inference_jobs\n WHERE status = \"started\"\n AND created_at > (CURDATE() - INTERVAL 5 MINUTE)\nUNION\n SELECT token\n FROM tts_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n) as t\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record_count",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 1,
"name": "present_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "6098826a8ada75442be5b7ac1d0199694e01c5959666f59986cd8b2ed6f51882"
}
@@ -0,0 +1,47 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -1,102 +0,0 @@
{
"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"
}
@@ -0,0 +1,256 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token,\n\n maybe_user_token,\n email_address,\n discord_username,\n\n is_for_private_use,\n is_for_public_use,\n\n is_for_studio,\n is_for_twitch_tts,\n is_for_api_use,\n is_for_music,\n is_for_games,\n is_for_other,\n optional_notes_on_use,\n\n is_own_voice,\n is_third_party_voice,\n optional_notes_on_subject,\n\n has_clean_audio_recordings,\n has_good_microphone,\n\n optional_questions,\n optional_extra_comments,\n\n created_at,\n updated_at\nFROM voice_clone_requests\nWHERE\n token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_user_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "discord_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "is_for_private_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "is_for_public_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "is_for_studio",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "is_for_twitch_tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_for_api_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "is_for_music",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 10,
"name": "is_for_games",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 11,
"name": "is_for_other",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "optional_notes_on_use",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 13,
"name": "is_own_voice",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 14,
"name": "is_third_party_voice",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 15,
"name": "optional_notes_on_subject",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "has_clean_audio_recordings",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 17,
"name": "has_good_microphone",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 18,
"name": "optional_questions",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 19,
"name": "optional_extra_comments",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 21,
"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,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "922a7dbcfa24a2c5901d0453c229b066b3b4eea2c73fa533651bb27365f17be0"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,135 @@
{
"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"
}
@@ -0,0 +1,69 @@
{
"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"
}
@@ -1,113 +0,0 @@
{
"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"
}
@@ -1,91 +0,0 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,256 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token,\n\n maybe_user_token,\n email_address,\n discord_username,\n\n is_for_private_use,\n is_for_public_use,\n\n is_for_studio,\n is_for_twitch_tts,\n is_for_api_use,\n is_for_music,\n is_for_games,\n is_for_other,\n optional_notes_on_use,\n\n is_own_voice,\n is_third_party_voice,\n optional_notes_on_subject,\n\n has_clean_audio_recordings,\n has_good_microphone,\n\n optional_questions,\n optional_extra_comments,\n\n created_at,\n updated_at\nFROM voice_clone_requests\nWHERE\n maybe_user_token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_user_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "discord_username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "is_for_private_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "is_for_public_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "is_for_studio",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "is_for_twitch_tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_for_api_use",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "is_for_music",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 10,
"name": "is_for_games",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 11,
"name": "is_for_other",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "optional_notes_on_use",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 13,
"name": "is_own_voice",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 14,
"name": "is_third_party_voice",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 15,
"name": "optional_notes_on_subject",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "has_clean_audio_recordings",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 17,
"name": "has_good_microphone",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 18,
"name": "optional_questions",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 19,
"name": "optional_extra_comments",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 20,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 21,
"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,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "e5ef2bda28221a570da6dcfa73e76cae9910a0b4ee53005a87176ecf1be73dd7"
}
-17
View File
@@ -77,20 +77,3 @@ 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
-16
View File
@@ -765,7 +765,6 @@ dependencies = [
"serde_json",
"strum",
"test_data",
"test_utils",
"tokens",
"tokio",
"url_utils",
@@ -3622,7 +3621,6 @@ dependencies = [
"env_logger 0.11.9",
"log",
"storyteller_root",
"trace_id",
"workspace-build-acceleration",
]
@@ -6768,7 +6766,6 @@ version = "0.0.1"
dependencies = [
"env_logger 0.11.9",
"log",
"trace_id",
"workspace-build-acceleration",
]
@@ -8289,7 +8286,6 @@ dependencies = [
"rootly_client",
"sha2 0.11.0-rc.5",
"tokio",
"trace_id",
"workspace-build-acceleration",
]
@@ -11682,7 +11678,6 @@ dependencies = [
"tokens",
"tokio",
"toml 1.0.7+spec-1.1.0",
"trace_id",
"tts_common",
"url",
"url_config",
@@ -13321,17 +13316,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "trace_id"
version = "0.0.1"
dependencies = [
"rand 0.8.5",
"serde",
"serde_derive",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "tracing"
version = "0.1.41"
-2
View File
@@ -130,7 +130,6 @@ members = [
"crates/schema/public/enums",
"crates/schema/public/reusable_types", # TODO(bt, 2022-12-21): Deprecate
"crates/schema/public/tokens",
"crates/schema/trace_id",
# Services: API servers, jobs, workers, etc.
"crates/service/job/gmicloud_job",
@@ -294,7 +293,6 @@ subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
test_data = { path = "crates/testing/test_data" }
test_utils = { path = "crates/testing/test_utils" }
tokens = { path = "crates/schema/public/tokens" }
trace_id = { path = "crates/schema/trace_id" }
url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
uuid_utils = { path = "crates/lib/uuid_utils" }
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX idx_creator_created_at,
ALGORITHM=INPLACE, LOCK=NONE;
@@ -1,17 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Composite index for the library / user profile list queries:
-- WHERE maybe_creator_user_token = ? ... ORDER BY created_at DESC LIMIT n
--
-- Without this, the optimizer picks an index-merge intersection against huge
-- low-selectivity indexes (e.g. index_user_deleted_at, ~4.75M NULL entries),
-- scanning millions of index records per page load.
--
-- NB: ADD INDEX is online DDL (ALGORITHM=INPLACE, LOCK=NONE) — reads and writes
-- continue during the build. Expect a few minutes of elevated I/O on a table
-- this size (~8.5M rows).
ALTER TABLE media_files
ADD INDEX idx_creator_created_at (maybe_creator_user_token, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
@@ -364,7 +364,6 @@ CREATE TABLE media_files (
KEY index_user_deleted_at (user_deleted_at),
KEY index_mod_deleted_at (mod_deleted_at),
KEY index_media_files_created_at (created_at),
KEY index_media_files_updated_at (updated_at),
KEY idx_creator_created_at (maybe_creator_user_token, created_at)
KEY index_media_files_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,6 +1,5 @@
pub mod alerts;
pub mod debug_logs;
pub mod top_spenders;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -1,82 +0,0 @@
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,
}
@@ -1 +0,0 @@
pub mod list;
@@ -1,5 +1,2 @@
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;
@@ -1,59 +0,0 @@
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>,
}
@@ -1,73 +0,0 @@
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>,
}
@@ -1,34 +0,0 @@
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,5 +1,2 @@
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;
@@ -1,32 +0,0 @@
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>,
}
@@ -1,32 +0,0 @@
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>,
}
@@ -1,32 +0,0 @@
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(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenVideoCostResponse {
pub success: bool,
@@ -1,5 +1,2 @@
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;
@@ -1,16 +0,0 @@
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>,
}
@@ -1,16 +0,0 @@
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>,
}
@@ -1,16 +0,0 @@
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,5 +1,2 @@
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;
@@ -1,139 +0,0 @@
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,
}
}
}
@@ -1,134 +0,0 @@
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,
}
}
}
@@ -1,98 +0,0 @@
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,
}
}
}
@@ -1 +0,0 @@
pub mod omni_gen_audio;
@@ -1,21 +0,0 @@
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?)
}
@@ -1 +0,0 @@
pub mod omni_gen_mesh;
@@ -1,21 +0,0 @@
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,5 +1,2 @@
pub mod audio;
pub mod image;
pub mod mesh;
pub mod splat;
pub mod video;
@@ -1 +0,0 @@
pub mod omni_gen_splat;
@@ -1,21 +0,0 @@
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,7 +39,6 @@ uuid.workspace = true
[dev-dependencies]
test_data.workspace = true
test_utils.workspace = true
strum.workspace = true
serde_json.workspace = true
@@ -1,9 +0,0 @@
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,16 +2,12 @@ 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;
@@ -1,65 +0,0 @@
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);
}
}
@@ -1,107 +0,0 @@
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,5 +9,4 @@ pub enum RouterProvider {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
@@ -5,68 +5,9 @@ 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,15 +98,6 @@ 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,
@@ -132,13 +123,4 @@ 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");
}
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Clone, Debug)]
pub enum VideoRef {
MediaFileToken(MediaFileToken),
Url(String),
}
@@ -8,4 +8,3 @@ 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,7 +3,6 @@ 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 {
@@ -12,7 +11,6 @@ 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 {
@@ -40,9 +38,4 @@ 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,7 +4,6 @@ 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>,
@@ -12,7 +11,6 @@ pub struct MultiRouterClientBuilder {
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
impl MultiRouterClientBuilder {
@@ -23,7 +21,6 @@ impl MultiRouterClientBuilder {
gmicloud_client: None,
grok_api_client: None,
seedance2pro_client: None,
worldlabs_client: None,
}
}
@@ -52,11 +49,6 @@ 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,
@@ -64,7 +56,6 @@ 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,7 +4,6 @@ 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 {
@@ -14,7 +13,6 @@ pub enum RouterClient {
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Seedance2Pro(RouterSeedance2ProClient),
WorldLabs(RouterWorldLabsClient),
}
impl RouterClient {
@@ -57,12 +55,4 @@ 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)),
}
}
}
@@ -1,16 +0,0 @@
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,7 +10,6 @@ pub enum ClientType {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
impl Display for ClientType {
@@ -21,7 +20,6 @@ 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,7 +5,6 @@ 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 {
@@ -14,7 +13,6 @@ pub enum ProviderError {
GmiCloud(GmiCloudError),
Grok(GrokError),
Seedance2Pro(Seedance2ProError),
WorldLabs(WorldLabsError),
}
impl Error for ProviderError {}
@@ -27,7 +25,6 @@ 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),
}
}
}
@@ -61,9 +58,3 @@ impl From<Seedance2ProError> for ProviderError {
Self::Seedance2Pro(error)
}
}
impl From<WorldLabsError> for ProviderError {
fn from(error: WorldLabsError) -> Self {
Self::WorldLabs(error)
}
}
@@ -1,16 +0,0 @@
/// 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>,
}
@@ -1,56 +0,0 @@
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))
},
}
}
}
@@ -1,36 +0,0 @@
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()
}
}
@@ -1,28 +0,0 @@
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(),
}
}
}
@@ -1,120 +0,0 @@
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
},
}
}
}
@@ -1,302 +0,0 @@
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)
}
}
}
@@ -1,65 +0,0 @@
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,
}
}
}
@@ -1,8 +0,0 @@
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;
@@ -1,143 +0,0 @@
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()
}
}
}
@@ -1,8 +0,0 @@
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;
@@ -1,29 +0,0 @@
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],
}))
}
@@ -1,34 +0,0 @@
//! 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))
}
}
}
@@ -1,14 +0,0 @@
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)))
}
@@ -1,51 +0,0 @@
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));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,78 +0,0 @@
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
}
}
@@ -1,14 +0,0 @@
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)))
}
@@ -1,51 +0,0 @@
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));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,79 +0,0 @@
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
}
}
@@ -1,14 +0,0 @@
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)))
}
@@ -1,57 +0,0 @@
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));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,88 +0,0 @@
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
}
}
@@ -1,14 +0,0 @@
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)))
}
@@ -1,57 +0,0 @@
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));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,89 +0,0 @@
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
}
}
@@ -1,14 +0,0 @@
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)))
}
@@ -1,51 +0,0 @@
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));
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,82 +0,0 @@
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
}
}
@@ -1 +0,0 @@
pub mod seed_audio_1p0;
@@ -1,588 +0,0 @@
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()
}
}
}
@@ -1,78 +0,0 @@
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()
}
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;

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