Compare commits

...
Author SHA1 Message Date
Hanashi 1c6da8d397 fix fal client error 2026-09-08 18:45:04 -04:00
bflatastic 976f2ae757 Webapp auth pages: video update 2026-09-04 00:24:18 +07:00
bflatastic f667353898 Website: Creator spotlight page 1 update 2026-09-02 01:29:26 +07:00
hanashi 0fa098dd38 more providers (#1922) 2026-09-01 10:45:02 -04:00
BFlat ce13ae68cb Website: Creator spotlight page 1 (#1921)
Adds a new creator spotlight landing page to the artcraft-website under
`/creators/`.

### Changes
- New spotlight page: hero with fanned reel triptych, credits marquee,
work grid, craft section, and closing CTA, all in the existing
landing-page design language
- Reel cards use locally hosted thumbnails (`public/images/creators/`)
and link out to the posts on Instagram, no embeds
- Primary "Create Now" CTA routes subscribers to the webapp and everyone
else to `/pricing`
- SEO wired up: `PAGE_META` entry for the prerender step, `_redirects`
rules plus short 301 aliases for the route
2026-09-01 21:28:37 +07:00
hanashi 393672988e mitigations to prevent stuck jobs (#1920)
- timeouts on db
- timeouts on http
- job health check loop staleness
- fail fast
2026-08-28 17:08:51 -04:00
BFlat 8d13d84109 Fix inflated cost quote after Recreate: measure restored ref durations (#1919)
Recreate restored reference videos/audios from the prompt record with duration: 0. The cost quote forwards that as a zero input-duration hint, and the backend deliberately prices a zero/unknown input at the worst-case 30-second billing maximum (Seedance 2.5 bills reference-video input seconds), so a recreated generation quoted far above the original
charge, e.g. a 1080p 10s output with ~5s of input quotes 548 credits, but 1096 with the failsafe.

Measure each restored reference's duration from its CDN copy while building the recreate payload, the same way the library "Send to prompt" path probes durations. Unmeasurable files keep 0 (worst-case quote as before; generation always bills from its own server-side measurement).
2026-08-28 12:11:19 +07:00
hanashi 44ff9ce338 mcp sessions (#1917) 2026-08-27 13:05:55 -04:00
BFlat bde7760080 Settings modal: billing tab with manage plan button (#1915)
Add Billing tab to webapp settings

- New "Billing" tab in the settings modal, between Account and API Keys
- Shows your current plan (Free/Basic/Pro/Max)
- Subscribers get a "Manage Plan" button that opens the Stripe portal,
  same as the pricing page, plus a "View Plans" link for switching
- Free users get a "View Plans" button to the pricing page
- Handles signed-out and loading states like the other tabs
2026-08-25 20:27:13 +07:00
Tanish Mankuandbflatastic e3f48033f9 Keybind settings: New menu and revamped keybind support (#1699)
## Summary

Adds a unified, remappable keyboard-shortcut system
(`@storyteller/keybinds`) across the webapp and desktop app, replacing
the scattered hardcoded `keydown` listeners in the 3D editor, 2D editor,
and moodboard with one registry, two presets (**Gamer** = today's
defaults, **Blender-like**), a Settings → Keybinds pane, and a hold-Ctrl
/ pinnable cheatsheet on every editor surface.

This PR was approved in June and then sat behind ~195 commits on `main`
(timeline, build/record modes, lucide migration). It has been merged
with current `main` and extended to cover the features that landed
since. **Nothing changes by default** — the Gamer preset reproduces
every existing key exactly.

## Architecture

- **Registry of `ActionDef`s** keyed by surface-namespaced ids
(`pagescene.timeline.playPause`), bound to physical `event.code`.
Resolution: user override → preset delta → base bindings. Persisted in
the `artcraft-keybinds` zustand store.
- **Five surfaces**: `global`, `pagescene` (3D), `pagedraw` (2D),
`moodboard` (canvas), `moodboard-grid` (board library / inspector /
presentation).
- **Declared availability** — `ActionDef.when(ctx)` gates decide whether
a binding is live for the current editor state (`sceneMode`, `encoding`,
`timelineExpanded`, `timelineSelection`, …). An unavailable binding
falls through *without consuming the event*, which is what lets Space /
arrows / Delete / Escape be shared by context-exclusive actions. A
sampled-context checker (`actionsCoAvailable`) proves the exclusivity in
tests and drives the conflict UI in Settings.
- **Global surface is a pure trigger layer**: features register behavior
with `useGlobalAction(id, handler)`; a window capture-phase dispatcher
fires above the modal key-swallow, and an unregistered binding is inert
per host. First inhabitant: Ctrl/Cmd+B sidebar. This is the intended
home for the upcoming privacy-blur toggle (one `ActionDef` + one binding
+ one `useGlobalAction` call).
- Cross-surface conflict detection (global ↔ everything, both
directions); preset-integrity tests guard every preset against
same-surface collisions.
- **Video editor keeps its own keybinds system** — deliberate non-goal.

## What's in it

**3D editor (pagescene)**
- Timeline actions: Space play/pause, ←/→ frame step or keyframe/strip
nudge, Home/End, `I` add keyframe (Blender parity), Shift+T expand,
Delete/Backspace on a valid timeline selection (exact complement of
scene-object delete).
- Record mode: Tab toggles build/record, `C` capture still, `R` record
video (bare letters — Firefox eats Ctrl+Shift+S/E), Escape cancels an
in-flight encode (overlay Cancel button too). Record mode is immutable:
all scene-mutating actions gate off, Escape no longer drops out of the
render cam.
- **Per-edit timeline undo** — every timeline edit is one Ctrl+Z step
(`TimelineEditAction` with whole-timeline snapshots; drags and the
easing popover coalesce into single steps). Save now only moves the
Cancel baseline; Cancel is itself undoable.
- The old "Shortcuts" modal is replaced by the live cheatsheet, pinnable
from the same button.
- Dead code removed: `MouseControls.onkeydown`, `OnboardingHelper`, the
hardcoded `Help` view, three unused pagedraw hotkey hooks.

**2D editor (pagedraw)**
- Tool hotkeys 1–5 with live key badges on the toolbar icons; the shape
key cycles rectangle → circle → triangle (submenu-aware).
- Ctrl+= / Ctrl+- (and numpad) resize the active tool. The eraser now
has its own size (was sharing the brush's) and its own slider popout;
also fixes the eraser button never highlighting.

**Moodboard**
- Canvas keys unchanged (now registry-backed), plus Ctrl+0 reset
viewport.
- Grid: rate 0–5 / `P` pick / Delete / Escape, inspector and
presentation ←/→/Space/Esc — all rebindable. Escape now closes the
inspector; arrows no longer navigate while typing in the tag field.

**Settings & cheatsheet**
- Settings → Keybinds: preset picker, name/key search, per-action rebind
with conflict warnings, per-section reset, and a bottom "danger zone"
reset-all with a two-step confirm (2 s lockout + visible 5 s
auto-disarm).
- Cheatsheet: hold Ctrl (⌘) 3 s to peek; **General → "Keep shortcut
cheatsheet open"** makes it stick until Esc / click-outside; the 3D
"Shortcuts" button pins it.

## Testing

- `@storyteller/keybinds` unit tests (29): resolution layering,
overrides, per-surface/global reset, conflict detection incl.
global↔surface, matcher semantics, and preset integrity across all
presets.
- Builds green: `keybinds`, `ui-pagescene`, `ui-pagedraw`,
`ui-moodboard`, `ui-settings-modal`, `artcraft-webapp`, `artcraft`
(desktop).
- Manual QA pass completed on the webapp for every area above (settings
pane, cheatsheets, Blender modal transforms, timeline/record keys,
pagedraw toolbar, global surface, reset UX, pinned cheatsheet, timeline
undo, Escape contexts).

## Notes for review

- The keybinds store is unversioned-migration-free on purpose — the PR
was never merged, so there's no persisted shape to migrate from.
- Space-hold pan (moodboard) and held WASD camera movement stay as
gestures, resolved from the same store but outside the discrete-action
dispatch.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-08-25 00:34:26 -07:00
BFlat 915728e9c7 Focus mode drag & drop (#1912)
Drag and drop didn't work while the prompt focus mode was open - the
modal sat on top of the inline promptbox's drop zone, so files just did
nothing (webapp and desktop).

### Changes

- `usePromptBoxDrop` now exposes `fullscreenDropZoneProps` - same
handlers/routing, second ref so the Tauri hit-test follows the
focus-mode modal while it's open
- `PromptFullscreenModal` (shared lib + webapp copy) takes optional
`dropZoneProps` / `dropOverlay` and renders the drop zone + hover
overlay on its panel
- Image / Video / Audio boxes + webapp `PromptBox` pass their existing
drop wiring through - one overlay element now serves both the inline box
and focus mode
- No new drop logic anywhere: same MIME routing, accept/reject toasts,
keyframe handling, and paste behavior as dropping on the inline box
2026-08-19 21:16:04 +07:00
BFlat 58d6d7f72e Fix iOS page-reload crash when adding image prompts + upload flicker (#1911)
On iOS, adding more than 2 image prompts can blow past the browser's
memory limit and force-reload the page, wiping your prompts. We were
storing every uploaded image as a full-res base64 string just to show a
56px thumbnail.
- Thumbnails are now small ~512px previews instead of full-res data URLs
(memory per image goes from ~100MB+ to under 1MB)
- Tapping a thumbnail still shows the original full-res image
- Nothing changes for the backend — uploads and generation still use the
original file + media token
- Fixed the flicker when an upload finishes: the spinner card now turns
into the image card in place, instead of a new card popping in next to
it
- Small cleanups along the way: leaked object URLs in the old prompt
row, and upload spinners that never actually spun

Applies to webapp (desktop + mobile), website, and the desktop app since
they share the upload code.
2026-08-19 20:54:57 +07:00
BFlat b55aa0d2cb Add last viewed badge for previously viewed media (#1910)
for both desktop and webapp
2026-08-18 11:06:28 +07:00
hanashi ce8b7e4637 Seedance 2.5 1080p (#1907) 2026-08-17 12:31:44 -04:00
hanashi 4975440053 rates (#1904) 2026-08-14 00:34:08 -04:00
hanashi f3442f3146 fix lossy margins (#1903) 2026-08-13 05:19:08 -04:00
hanashi ac613b007b unify pricing for legacy endpoint (#1901) 2026-08-13 02:17:07 -04:00
hanashi 47aa6a6c06 no implicit 720p anymore - make this explicit (#1900) 2026-08-13 01:40:51 -04:00
hanashi da6a6c8441 don't send default resolution anymore (#1899) 2026-08-13 01:29:35 -04:00
Hanashi 5cacd89ab8 fix build error 2026-08-12 23:16:27 -04:00
BFlat 85e4c15598 Fix slow prompt paste and focus-mode model submenu scroll (#1898)
## Fixes
- Slow paste in prompt box: `execCommand("insertText")` is quadratic per
line in Chromium - now inserts the clipboard as a single text node
(linear)
- Model selector submenu not scrolling in focus mode: the modal's scroll
lock swallows wheel/touch on body-portaled panels - flyout now stops
propagation (same pattern as mention dropdown)
2026-08-13 07:42:18 +07:00
hanashi a10b49b4c6 fix veo negative margins (#1897) 2026-08-12 18:36:01 -04:00
hanashi bc198875ef fix seedream batches (#1896) 2026-08-12 18:13:57 -04:00
hanashi 178e6de3c6 move pricing for easier testing (#1895) 2026-08-12 18:07:09 -04:00
BFlat b65372beb7 Fix: reference deck overflows focus mode with many refs (#1883)
- Cap the always-expanded deck panel at container width so cards wrap
into rows; scroll past 3 rows instead of squeezing the editor
- Drop shrink-0 on the expanded deck root (min-w-0 max-w-full) so it
shrinks inside the webapp's flex-row wrapper
- Collapsed fan badge shows total ref count instead of +hidden
2026-08-11 23:32:44 +07:00
bflatastic 5f04b6dbee Hotfix: model selector popover submenu hover broken in promptbox focus mode 2026-08-11 22:54:01 +07:00
bflatastic 9a57fa0c3a Hotfix: edit image page crash 2026-08-11 22:37:59 +07:00
BFlat c96d979585 Fix: focus-mode prompt box not scrolling (#1882)
MentionTextarea's root wrapper lacked min-h-0, so as a flex child of the
fullscreen modal's bounded column it kept full content height and got
clipped by overflow-hidden instead of letting the editor scroll.
2026-08-11 11:02:08 +07:00
hanashi 343c2a0e47 margin fixes (#1881) 2026-08-10 23:20:50 -04:00
BFlat 48d2092edb Promptbox: image ref mention chips (#1880)
- `@ImageN` mentions now render as thumbnail chips, same as characters
- Chip menu (Replace / Preview / Remove) works on image chips; Replace
lists same-type refs only
- Chip menu shows type-aware subtitle, empty text, and fallback icon
- Fix: webapp chip Preview showed thumbnail-size image - now passes
full-res `fullUrl`
- Video/audio mentions unchanged (still colored text); website's old
copy untouched
2026-08-10 21:50:49 +07:00
hanashi 865fcb8b56 batch sizes (#1879) 2026-08-10 01:59:09 -04:00
hanashi f563b06614 Cleanup dead enums (#1878) 2026-08-10 01:50:46 -04:00
hanashi 0a84a3ce80 fix codegen + also codegen query cache (#1877) 2026-08-10 00:10:55 -04:00
hanashi 91762e2f6c Merge omni api with omni gen (#1876) 2026-08-09 23:54:01 -04:00
hanashi c62a269638 testing omni gen (#1875) 2026-08-09 22:54:25 -04:00
BFlat b36e4fd27b Refactor: replace fontawesome icons with lucide icons (#1873)
Removes Font Awesome entirely

### Changes

- All ~1,000 icon call sites now import Lucide components directly from
`lucide-react` (`<PlayIcon className="..." />`); no FA packages, tokens,
or `fa*` identifiers remain anywhere.
- New `@storyteller/icons` lib holds the pieces Lucide doesn't ship:
`DynamicIcon` for runtime-chosen icons (`icon={loading ?
LoaderCircleIcon : SaveIcon}`), 11 brand logos (Discord, TikTok, ...) as
inlined CC BY 4.0 paths, and HD/SD quality badges drawn in Lucide's
stroke style.
- Global `.lucide { display: inline-block; width/height: 1.2em }` rule
in each app stylesheet: keeps icons font-relative like FA (1.2em
compensates Lucide's 24px-viewBox/20px-grid padding) and undoes Tailwind
preflight's `svg { display: block }`. Explicit `w-*`/`size-*` utilities
still win.
- FA-only props migrated: `spin` → `animate-spin`, FA size strings → em
values.
- Dropped `@fortawesome/*` + `@awesome.me` kit deps, both `.npmrc`
files, and
the FA rule in `shared-vite-config.ts` (now externalizes
`lucide-react`).
2026-08-10 02:04:45 +07:00
hanashi f767fc9638 cleanup (#1872) 2026-08-08 22:33:46 -04:00
hanashi 224e5c17f5 fix billing issue (#1871) 2026-08-08 21:58:38 -04:00
hanashi 534337aa3a seedance 2.5 ultra (#1870) 2026-08-08 21:46:14 -04:00
hanashi ea4563c1a8 Frontend dev exp (#1866) 2026-08-08 19:17:34 -04:00
hanashi 9646a791aa minimax feature (#1865) 2026-08-08 16:34:49 -04:00
BFlat 4d839d3b8b Library/video promptbox: bulk "Add tags" action + default video mode to Seedance 2.5 (#1864)
- Select multiple items in the library and add tags to all of them at
once
- Tag suggestions pop up as you type, based on your existing tags
- Fixed the selection bar looking squished (buttons no longer wrap onto
two lines)
- Video generation now defaults to Seedance 2.5 (was 2.0)
- Omni Reference is the default video input mode for everyone
2026-08-08 15:41:13 +07:00
hanashi b7f354face fix seedance 2.5 estimates (#1863) 2026-08-08 00:46:06 -04:00
hanashi c888a494b9 seedance 2.5 bindings (#1862) 2026-08-07 23:41:44 -04:00
Tanish Manku a048458956 3D: Animation clip blending (#1861) 2026-08-07 18:48:54 -07:00
BFlat d598896e40 Model picker: add scroll fade + chevron indicators to submenu flyout (#1860)
- Bring the main panel's top/bottom gradient fades and bouncing scroll
hint arrows over to the family flyout list.
2026-08-07 23:48:20 +07:00
BFlat af9dfa556f Model picker: grouped submenus, color icons, capability tags (#1859)
- Model pickers on the create pages now group models by family
(Seedance, Veo, Kling, etc.) with a flyout submenu per family, so the
list is no longer one long scroll
- The flyout opens on hover or click, and automatically scrolls the
currently selected model into view
- New setting in **Settings → General** ("Group models by family") to
switch back to the classic flat list. Grouped is the default and works
in both the webapp and the desktop app
- Model rows now use full-color brand icons, while the picker button
keeps its existing look
- Video models show capability tags: **Audio Support**, **Start/End**,
and **Reference**
- Short model lists (under 8 models) stay flat, and mobile keeps the
existing bottom-sheet list
- Fix: videos in the media viewer can now go fullscreen (the expand
button was disabled)
2026-08-07 23:36:59 +07:00
Tanish Manku ea0de174c9 3D: Fix CI/CD deploy (worker vite build issue) (#1858) 2026-08-07 08:47:10 -07:00
Tanish MankuandBFlat a1435df07d 3D: Animations support + UI (#1856)
Co-authored-by: BFlat <bombayapirat@gmail.com>
2026-08-06 22:48:35 -07:00
hanashi a7a32a3862 minimax worker endpoints (#1855) 2026-08-07 01:24:20 -04:00
BFlat bb10c993a4 Hotfix: Minimax landing scroll issue 2026-08-07 10:48:53 +07:00
BFlat c6474edd5c Website: MiniMax H3 marketing page (#1854)
## Summary

New marketing page at /minimax-h3 where visitors can try MiniMax H3 for
free,
right on the page.

- Hero is a working promptbox, locked to MiniMax H3 with a "Free" badge
- Full settings available: aspect ratio, resolution, duration, sound,
start/end frames
- New users sign up in a modal when they hit Generate (Google or email),
then their prompt runs immediately without leaving the page
- Generated videos show up right below the promptbox, with a queue
notice and a CTA to keep creating in the app while they wait
- Example videos from MiniMax's announcement, self-hosted with
thumbnails, only load when played, credited to MiniMax
- Overview, highlights, prompt tips, and FAQ copy making the free offer
clear Website promptbox updated to match the current webapp promptbox
(reference deck, drag & drop, focus mode, same buttons)
- SEO, redirects, and URL aliases (/minimax, /hailuo) wired up
2026-08-07 10:41:07 +07:00
hanashi 9020f0aa5b minimax turbo (#1852) 2026-08-06 18:21:28 -04:00
BFlat c2e3c27a72 Model list: names, icons, descriptions for new omni gen models (MiniM… (#1851)
…ax H3, Flux 3, Veo 3.1 Lite, Vidu Q3, 3D/world)
2026-08-06 23:32:37 +07:00
BFlat 22847961e1 Admin dashboard: bulk ban (#1850) 2026-08-06 22:58:37 +07:00
BFlat f72981875c Website: beta sign up remove scripts (#1849) 2026-08-06 13:07:13 +07:00
BFlat 39e89c76e8 Website: beta sign up page (#1848) 2026-08-06 12:43:47 +07:00
hanashi a473be78bd new endpoint for media files by job token (#1846) 2026-08-04 23:13:30 -04:00
hanashi 36cfba0000 media files - record job token (#1845) 2026-08-04 22:09:08 -04:00
hanashi a5f59d7995 Flux3 (#1844) 2026-08-04 15:12:32 -04:00
bflatastic 6329045350 Hotfix: mention autocomplete popover colors + darker chips on mobile 2026-08-04 23:19:49 +07:00
bflatastic cbfed0017b Hotfix: mobile mention popover positioning, chip replace closing early & duplicating text 2026-08-04 23:08:46 +07:00
bflatastic 099e3cb27b Hotfix: mobile mention autocomplete taps not selecting 2026-08-04 22:51:47 +07:00
BFlat 9c18f746d4 Character preview & mobile drawer improvements (#1843)
- Characters modal: new eye button on character cards to view the image
full size
- Mention chip menu: tapping the avatar thumbnail also opens the
full-size view
- Card buttons (view/edit/delete) are always visible on touch devices
instead of hover-only
- Mobile: Characters open in a bottom drawer with a 2-column grid,
matching the other settings drawers
- All mobile drawers now have a close (X) button
- All mobile drawers can be pulled down to dismiss - the sheet follows
your thumb and closes from where you let go
2026-08-04 22:38:32 +07:00
BFlat e4fc9446d8 Mention fixes (promptbox) (#1842)
- Mobile: mention autocomplete list can now be scrolled - dragging no
longer selects an item, only a tap does
- Fixed taps on autocomplete items sometimes not registering on
Android/iOS
- Deleting the space after a mention now reopens the autocomplete next
to the mention instead of at the top of the screen
- Clicking a mention chip while its menu is open now closes the menu
instead of closing and reopening it (desktop + mobile)
2026-08-04 21:08:21 +07:00
hanashi 37b7f3e88f Minimax (#1840) 2026-08-04 05:31:17 -04:00
hanashi 60b4148179 web + API for omni gen endpoints (#1836) 2026-08-03 16:22:16 -04:00
hanashi a4d9a4d199 this fixes another set of negative margins (#1834) 2026-08-02 18:22:04 -04:00
hanashi 2b8cc5ef8d margins for video references were costing us money, fixing (#1833) 2026-08-02 17:29:06 -04:00
hanashi 9122be6752 we're losing money on seedance in this configuration (need to fix) (#1832) 2026-08-02 17:02:49 -04:00
hanashi 31054141cc costs for seedance 2.5 are costing us money (#1831)
we're losing money on this model, gotta plug it (there are other holes
to fix too)
2026-08-02 15:58:08 -04:00
BFlat 6b1b4c7b0d temporarily hide downloads (#1830) 2026-08-02 10:57:54 +07:00
BFlat 5f46ee0e7e Added missing model names & icons mappings (#1829)
- Added missing display names for Seedance 2.5 Preview and ~25 other
models (Vidu Q3, Veo 3.1 Lite, Seedream 5.0 Pro, new Hunyuan 3D 3.1
variants, Marble 1.x, and more) so the task queue and history show
proper names instead of raw IDs.
- Vidu, Recraft, and SeedEdit models now show their brand icons instead
of the generic one.
2026-08-02 07:47:43 +07:00
BFlat 9cdad367c0 model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)
## Fixes & improvements

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

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

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
2026-08-02 07:32:41 +07:00
hanashi c026a7c572 seedance 2.5 preview in Omni API (#1827) 2026-08-01 16:07:05 -04:00
hanashi 62051aa49f create material (#1826) 2026-08-01 10:48:19 -04:00
hanashi a71a4b9613 seedance2p5 (#1825) 2026-07-31 18:12:27 -04:00
hanashi 9737f0ef88 rename client (#1824) 2026-07-31 15:10:30 -04:00
1251 changed files with 53865 additions and 18194 deletions
+20
View File
@@ -1,5 +1,25 @@
# Testing
## Database Fixture Tests
- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and
use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for
guarded pools, schema setup, and account/session/wallet fixtures
- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE
(requires a local MySQL with an `artcraft_test` database). Machines/CI
without MySQL skip them via the off switch:
`SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests`
- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default
`mysql://root:@localhost:3306/artcraft_test`); the guard panics on any
database whose name lacks "test", on `storyteller`/`artcraft`, and on
cloud hosts — see `mysql_testing/README.md`
- Database tests run in PARALLEL: each test creates its own users, wallets,
and rows; schema setup single-flights via a MySQL named lock. Only tests
mutating shared/global state take
`mysql_testing::serial::acquire_serial_test_lock().await`
- External providers must never be called: the harness stubs Kinovi with an
in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST`
## Running Tests
- Most crates: `cargo test -p {crate_name}`
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -72,6 +72,26 @@
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
@@ -84,8 +104,10 @@
false,
false,
false,
true
true,
true,
false
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\nFROM generic_inference_jobs\nWHERE job_type = ?\nAND status = 'pending'\nORDER BY id ASC\nLIMIT 1\nFOR UPDATE SKIP LOCKED\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE private_session_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a"
}
@@ -0,0 +1,113 @@
{
"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 users.maybe_feature_flags,\n mcp_sessions.token as `mcp_session_token: McpSessionToken`\nFROM mcp_sessions\nINNER JOIN users\n ON users.token = mcp_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE mcp_sessions.private_session_token = ?\n AND mcp_sessions.maybe_deleted_at IS NULL\n AND mcp_sessions.expires_at > NOW()\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": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "mcp_session_token: McpSessionToken",
"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,
true,
false
]
},
"hash": "847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n frontend_failure_category = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n success_execution_millis = ?,\n success_inference_execution_millis = ?\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
"max_size": 1020
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
false
true
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 42
},
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n private_refresh_token = ?,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND private_refresh_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
true,
true,
true,
true
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n private_refresh_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e"
}
Generated
+44 -27
View File
@@ -757,9 +757,9 @@ dependencies = [
"fal_client",
"gmicloud_client",
"grok_api_client",
"kinovi_web_client",
"log",
"reqwest 0.12.23",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -6359,6 +6359,29 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kinovi_web_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "kubectl-pod-cleanup"
version = "0.0.1"
@@ -7340,6 +7363,22 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "mysql_testing"
version = "0.1.0"
dependencies = [
"anyhow",
"bcrypt",
"bucket_paths",
"chrono",
"enums",
"log",
"mysql_queries",
"sqlx",
"tokens",
"tokio",
]
[[package]]
name = "mysqlclient-sys"
version = "0.2.5"
@@ -10519,6 +10558,7 @@ dependencies = [
"errors",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mysql_queries",
"pager",
@@ -10526,7 +10566,6 @@ dependencies = [
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -10538,29 +10577,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "seedance2pro_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -11632,6 +11648,7 @@ dependencies = [
"images",
"infer 0.16.0",
"jwt_signer",
"kinovi_web_client",
"language-tags",
"lexical-sort",
"limitation",
@@ -11645,6 +11662,7 @@ dependencies = [
"migration",
"mimetypes",
"mysql_queries",
"mysql_testing",
"once_cell",
"opaque_cursors",
"pager",
@@ -11663,7 +11681,6 @@ dependencies = [
"rootly_config",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -11867,8 +11884,8 @@ dependencies = [
"easyenv",
"enums",
"errors",
"kinovi_web_client",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
+4 -2
View File
@@ -27,7 +27,7 @@ members = [
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/kinovi_web_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
@@ -120,6 +120,7 @@ members = [
"crates/schema/database/elasticsearch_schema",
"crates/schema/database/migration",
"crates/schema/database/mysql_queries",
"crates/schema/database/mysql_testing",
"crates/schema/database/redis_common",
"crates/schema/database/redis_schema",
"crates/schema/database/sqlite_tasks",
@@ -282,10 +283,11 @@ mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
metrics = { path = "crates/lib/metrics" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
mysql_testing = { path = "crates/schema/database/mysql_testing" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
kinovi_web_client = { path = "crates/api_clients/kinovi_web_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -0,0 +1,13 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
ALTER TABLE media_files
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
-- For "outputs of this job"-style lookups.
-- The column is all-NULL at creation time, so the index build is cheap.
ALTER TABLE media_files
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS mcp_sessions;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- Private entropy presented by clients; used for lookup of live sessions.
private_session_token VARCHAR(64) NOT NULL,
-- Private refresh credential. Presenting this on the refresh endpoint extends the
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
private_refresh_token VARCHAR(64) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (private_session_token),
UNIQUE KEY (private_refresh_token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- Private entropy presented by clients; used for lookup of live sessions.
private_session_token VARCHAR(64) NOT NULL,
-- Private refresh credential. Presenting this on the refresh endpoint extends the
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
private_refresh_token VARCHAR(64) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (private_session_token),
UNIQUE KEY (private_refresh_token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -102,6 +102,10 @@ CREATE TABLE media_files (
-- If the media file is generated as part of a batch, this designates the batch.
maybe_batch_token VARCHAR(32) DEFAULT NULL,
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
-- ========== METADATA ==========
-- If the file is a user upload (not including studio renders).
@@ -327,6 +331,7 @@ CREATE TABLE media_files (
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
KEY index_maybe_batch_token (maybe_batch_token),
KEY index_maybe_source_job_token (maybe_source_job_token),
KEY index_is_user_upload (is_user_upload),
KEY index_is_intermediate_system_file (is_intermediate_system_file),
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
+227
View File
@@ -0,0 +1,227 @@
# PageScene Animations — Branch Handoff
Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04).
Everything below is TypeScript-typechecked per-lib (`tsc -p <lib>/tsconfig.lib.json`); runtime
verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual
pass (confirmed working); most other surfaces have not been launched.
## What this branch does
Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the
asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback,
play clips baked into uploaded models, preview animations everywhere models are previewed, and
upload Mixamo FBX files (with or without mesh) end to end.
## Commit map
| Commit | What |
|--------------|-------------------------------------------------------------------------|
| `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired |
| `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge |
| `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) |
| `142279414e` | Animation dropdown in Viewer3D + upload preview |
| `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo |
| `60251104e3` | FBX accepted at the picker, converted to GLB in-browser |
| `0942ec4804` | Nx tsconfig references for the new ui-select dependency |
| `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) |
| `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis |
| `00ebeae10c` | User's uploaded animations merged into the Animations tab |
| `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner |
| `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes |
| `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life |
| `d55735620a` | Timeline ruler tick marks + strip button spacing |
| `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) |
| *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs |
## Features
### 1. Animations tab in the asset drawer (`AssetModal`)
- New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation`
(GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded
`demoAnimationItems`; demos are only the empty-API fallback.
- The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the
featured set. Fetched on every modal open and after each upload; fails silently for anonymous
users (`suppressErrorToast` on the user-media fetch path).
- Card click appends the clip to the selected character's row at the earliest free slot (toast
when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is
deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it.
### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`)
- Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the
clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a
character (or any skinned object) checks those names against its nodes — the exact direct-bind
rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope).
- The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig
mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are
rejected with a toast.
- Drop targets: a character in the 3D scene (earliest free slot) or a timeline row
(`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect).
### 3. Timeline clips: behavior + UX
- **Strips default to 1 s** and the strip width is the authoritative play window
(`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the
strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only
shrinks (clip shorter than default) — never grows.
- **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at
mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap
back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that
was the "character vanishes when moving a strip" bug.
- **Selection UX**: strips are click-to-select; × renders only on the selected strip;
Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one
exception to clip edits riding the timeline Save/Cancel session).
- **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an
overlapping clamp.
### 4. Baked-in clips
- `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]`
(resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row
with a "+" picker listing them.
- Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads)
accepts animation drags (bind check gates); baked-only objects get a row for the picker.
- INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only
unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay
fully removable.
- Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed
after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`).
### 5. Previewers (Viewer3D + upload preview)
- Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden
entirely for animation-less models.
- Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh
(Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail
snapshot then captures the skeleton render.
- The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`;
mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper`
(exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a
mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The
toggle shows for `hasBones || !hasMesh`.
- Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop
skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before
the model entered the scene (silently played nothing); camera framing NaN'd on empty
bounding boxes (now falls back to bone world positions); camera now fits once, not per-child.
### 6. FBX upload support
- The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter`
→ binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs
naturally serialized); a broken worker environment falls back to the main thread. The raw FBX
can never reach the backend — retry of a failed conversion re-converts.
- Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo
rigs are not rescaled).
### 7. Upload as Animation
- Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`.
Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always
wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first).
- The backend-required `maybe_duration_millis` is read from the longest clip at submit time;
clip-less files are flagged and excluded client-side.
### 8. Outliner skeleton toggle (`SkeletonHelperController`)
- Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only —
the render camera renders layer 0, so skeletons never appear in captures/recordings).
- Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy
load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the
post-GLB-load refresh all funnel there.
- FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for
the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself
is never touched.
## Manual test checklist (outstanding)
- [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes
they replace the demo clips in the tab; if empty, the 37 demos show.
- [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*`
assumption; a "bound 0 tracks" console warning means retargeting becomes the real task).
- [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result.
- [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload
persistence (the `object.animations` stash on load is gated on `auto_add` — statically
unconfirmed for every load path).
- [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal;
character stays visible when a strip moves off the playhead.
- [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures —
falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation
preselected → appears at front of Animations tab → drag onto character → plays.
- [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a
normalize-scale step.
- [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and
absence from Capture/Record output.
- [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal,
ImageTo3DExperience, upload modal.
## Debug pass + hardening round (post-review)
A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low);
ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`).
Highlights, roughly in commit order:
- Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption);
runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later
hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on
root-instance change; the pre-fetch rig gate is sound because every load path stamps the target
uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal
can no longer report success for empty/partial submissions; ref-guarded double-submit.
- Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect,
no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before
swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch
frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at
save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest
front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared
lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists);
previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing
fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot
after helper creation, File-identity row statuses, retry re-entry).
- Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list;
timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every
drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root
lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset;
conversion completions reload only the previewed entry; worker `onmessageerror` recovery +
corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less
auto-detect; Uploaded tab pages past the 100-item cap.
- Closed without code: the "compat cache stales on streaming characters" finding (invalidated —
uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is
always signed in by adapter contract).
## Landed since the original handoff
- **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal
with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose
Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the
character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after
adding" is on. Its "Upload animation" button opens `UploadModal3D` with
`initialCategory="animation"` (anonymous users get the signup prompt), landing on the
Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this.
- **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track
filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon
placeholder.
## Deferred (explicitly out of scope for this branch)
- **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until
explicit approval.
- **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are
simply rejected by the bind check.
- **Clip blending/layering** — one non-overlapping row per object, by design.
## Key files
| Area | Files |
|---------------------|-------------------------------------------------------------------------------------------|
| Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` |
| Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` |
| Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, |
| | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` |
| Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` |
| Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` |
| Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` |
| FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, |
| | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` |
The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these
changes and carries the same invariants in engine-doc form.
+2 -2
View File
@@ -51,9 +51,9 @@ helpers go in the parent `mod tests` so sub-modules can `use super::*`.
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, KinoviWeb, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `kinovi_web_client` — HTTP client for the Kinovi/KinoviWeb video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureRequest {
/// Optional failure class the frontend can localize and present.
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// Optional failure reason we can show the user (512 characters max).
pub maybe_user_failure_reason: Option<String>,
/// Optional internal-only stack trace or error (512 characters max).
pub maybe_internal_debugging_failure_reason: Option<String>,
/// Optional total wall-clock runtime of the failed attempt, in milliseconds.
pub execution_duration_millis: Option<u64>,
/// Optional inference-only runtime of the failed attempt, in milliseconds.
pub inference_duration_millis: Option<u64>,
}
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureResponse {
pub success: bool,
}
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// NB: The success request is form-multipart (video bytes + optional
// `duration_millis` / `width` / `height` metadata fields), so its form struct
// lives with the actix handler; only the response shape is declared here.
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobSuccessResponse {
pub success: bool,
/// The media file created from the uploaded video.
pub media_file_token: MediaFileToken,
/// Video duration stored on the media file record, in milliseconds
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_duration_millis: Option<u64>,
/// Frame width stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_width: Option<u32>,
/// Frame height stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_height: Option<u32>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The first-party Minimax models a worker can request jobs for.
///
/// This is deliberately its own enum (not `CommonVideoModel`) so the internal
/// worker API surface stays small and stable.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
pub enum MinimaxWorkerModel {
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip() {
let cases = [
(MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""),
(MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""),
];
for (variant, json) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), json);
let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap();
assert_eq!(parsed, variant);
}
}
}
@@ -0,0 +1,4 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -0,0 +1,85 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobRequest {
/// Which model the worker wants a job for.
pub model: MinimaxWorkerModel,
/// The worker's hostname (linux hostname, k8s pod name), if reported.
pub worker_hostname: Option<String>,
/// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported.
pub cluster_name: Option<String>,
}
/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobResponse {
pub success: bool,
/// The obtained job, or `None` when no pending job is available.
pub maybe_job: Option<ObtainedMinimaxJob>,
}
/// A job the worker now holds a lock on (status was moved to `started`).
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainedMinimaxJob {
pub job_token: InferenceJobToken,
/// Echo of the requested model.
pub model: MinimaxWorkerModel,
/// The prompt and inputs the worker needs to run inference. Only `None`
/// when the job was somehow enqueued without a prompt record (or the
/// prompt lookup failed) — workers should fail such jobs via the failure
/// endpoint.
pub maybe_prompt: Option<MinimaxJobPromptDetails>,
}
/// The prompt (text + generation settings) and its attached media references.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobPromptDetails {
pub prompt_token: PromptToken,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_bitrate: Option<CommonBitrate>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
/// Image / video / audio references attached to the prompt, with full CDN
/// links, in their original order.
pub media_references: Vec<MinimaxJobMediaReference>,
}
/// One media input attached to the prompt (start frame, end frame, or an
/// image / video / audio reference), with full CDN links.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobMediaReference {
pub media_file_token: MediaFileToken,
/// The role the media plays (start frame, end frame, image / video / audio
/// reference, etc.).
pub semantic_type: PromptContextSemanticType,
/// Full CDN links to the media file.
pub media_links: MediaLinks,
}
@@ -0,0 +1,4 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,7 +5,9 @@ pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod mcp_sessions;
pub mod media_file;
pub mod moderation;
pub mod omni_api;
@@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_sessions::McpSessionToken;
/// Canonical wire shape for an MCP session, used by the dashboard list.
///
/// NB: the secret `private_session_token` is NEVER included here — it is returned exactly once,
/// at creation time. The `token` is the non-private management handle.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct McpSessionInfo {
pub token: McpSessionToken,
/// Trimmed name of the MCP client that created the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Trimmed version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Trimmed vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// When the session expires, if not refreshed.
pub expires_at: DateTime<Utc>,
/// Termination timestamp. `None` for live (though possibly expired) sessions.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the session's management `token` (NOT the secret
/// `private_session_token`): `POST /v1/mcp/session/{token}/delete`.
#[derive(Deserialize, ToSchema)]
pub struct McpSessionPathInfo {
pub token: McpSessionToken,
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
// ── POST /v1/mcp/session/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateMcpSessionRequest {
/// Name of the MCP client creating the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateMcpSessionSuccessResponse {
pub success: bool,
/// The secret credential the MCP client presents in the `Authorization` header. This is the
/// ONLY time it is ever returned — the caller must store it now, as it cannot be retrieved
/// again.
pub private_session_token: McpSessionPrivateToken,
/// The secret credential the MCP client presents on `/v1/mcp/session/refresh` to extend the
/// session. Each successful refresh returns a NEW refresh token and invalidates this one —
/// store whichever value is newest.
pub private_refresh_token: McpSessionRefreshToken,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/{token}/delete ──
#[derive(Serialize, ToSchema)]
pub struct DeleteMcpSessionSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,24 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::mcp_sessions::common::McpSessionInfo;
// ── GET /v1/mcp/session/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMcpSessionsQueryParams {
/// Page size. Defaults server-side if omitted.
pub limit: Option<u32>,
/// Number of rows to skip. Defaults to 0.
pub offset: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListMcpSessionsSuccessResponse {
pub success: bool,
/// The user's MCP sessions (including terminated ones), newest first. The secret
/// `private_session_token` is never listed.
pub mcp_sessions: Vec<McpSessionInfo>,
}
@@ -0,0 +1,6 @@
pub mod common;
pub mod create_mcp_session;
pub mod delete_mcp_session;
pub mod list_mcp_sessions;
pub mod refresh_mcp_session;
pub mod revoke_mcp_session;
@@ -0,0 +1,22 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
// ── POST /v1/mcp/session/refresh ──
#[derive(Deserialize, ToSchema)]
pub struct RefreshMcpSessionRequest {
/// The session's CURRENT refresh token (from creation, or from the most recent refresh).
/// A stale or wrong value is an error — refresh tokens rotate and are never replayable.
pub private_refresh_token: McpSessionRefreshToken,
}
#[derive(Serialize, ToSchema)]
pub struct RefreshMcpSessionSuccessResponse {
pub success: bool,
/// The NEW refresh token to present on the next refresh. The one just used is now invalid —
/// the caller must store this replacement.
pub private_refresh_token: McpSessionRefreshToken,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/revoke ──
#[derive(Serialize, ToSchema)]
pub struct RevokeMcpSessionSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,61 @@
//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files
//! produced by an inference job.
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesByJobSuccessResponse {
pub success: bool,
/// The job's output files, oldest first.
pub media_files: Vec<JobMediaFileInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct JobMediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1 @@
pub mod list_media_files_by_job;
@@ -1,4 +1,5 @@
pub mod delete_media_file;
pub mod job;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
@@ -69,4 +69,23 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Some models will default this to true, others will default it to false,
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// Frontend-supplied hints for cost estimation only. IGNORED by
/// generation — the generate endpoints measure inputs themselves (e.g.
/// ffprobing reference videos) and bill from those measurements.
pub estimate_only: Option<EstimateFields>,
}
/// These fields are ignored by generation. They're only used by the frontend
/// to aid in cost estimation (e.g. Seedance 2.5 bills reference-video input
/// seconds, so the cost quote needs the combined input duration without the
/// server downloading and probing the files on every poll).
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, Debug, Default)]
pub struct EstimateFields {
/// Combined duration of all reference video inputs, in milliseconds.
pub total_input_video_duration_millis: Option<u32>,
/// Combined duration of all reference audio inputs, in milliseconds.
/// (No model bills audio input seconds yet; accepted for future models.)
pub total_input_audio_duration_millis: Option<u32>,
}
@@ -11,7 +11,7 @@ pub struct OmniGenVideoGenerateResponse {
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
/// `inference_job_token`. For batch requests (eg. KinoviWeb with
/// multiple order IDs) this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -14,6 +14,14 @@ pub enum ApiClientGenerationProvider {
Midjourney,
Sora,
WorldLabs,
Higgsfield,
Krea,
Leonardo,
Magnific,
Openart,
Picsart,
Pixverse,
Runway,
/// A value not known to this client build (preserved as-is).
Unknown(String),
}
@@ -27,6 +35,14 @@ impl From<String> for ApiClientGenerationProvider {
"midjourney" => Self::Midjourney,
"sora" => Self::Sora,
"world_labs" => Self::WorldLabs,
"higgsfield" => Self::Higgsfield,
"krea" => Self::Krea,
"leonardo" => Self::Leonardo,
"magnific" => Self::Magnific,
"openart" => Self::Openart,
"picsart" => Self::Picsart,
"pixverse" => Self::Pixverse,
"runway" => Self::Runway,
_ => Self::Unknown(value),
}
}
@@ -41,6 +57,14 @@ impl From<ApiClientGenerationProvider> for String {
ApiClientGenerationProvider::Midjourney => "midjourney".to_string(),
ApiClientGenerationProvider::Sora => "sora".to_string(),
ApiClientGenerationProvider::WorldLabs => "world_labs".to_string(),
ApiClientGenerationProvider::Higgsfield => "higgsfield".to_string(),
ApiClientGenerationProvider::Krea => "krea".to_string(),
ApiClientGenerationProvider::Leonardo => "leonardo".to_string(),
ApiClientGenerationProvider::Magnific => "magnific".to_string(),
ApiClientGenerationProvider::Openart => "openart".to_string(),
ApiClientGenerationProvider::Picsart => "picsart".to_string(),
ApiClientGenerationProvider::Pixverse => "pixverse".to_string(),
ApiClientGenerationProvider::Runway => "runway".to_string(),
ApiClientGenerationProvider::Unknown(other) => other,
}
}
@@ -73,6 +97,14 @@ mod tests {
(ApiClientGenerationProvider::Midjourney, "midjourney"),
(ApiClientGenerationProvider::Sora, "sora"),
(ApiClientGenerationProvider::WorldLabs, "world_labs"),
(ApiClientGenerationProvider::Higgsfield, "higgsfield"),
(ApiClientGenerationProvider::Krea, "krea"),
(ApiClientGenerationProvider::Leonardo, "leonardo"),
(ApiClientGenerationProvider::Magnific, "magnific"),
(ApiClientGenerationProvider::Openart, "openart"),
(ApiClientGenerationProvider::Picsart, "picsart"),
(ApiClientGenerationProvider::Pixverse, "pixverse"),
(ApiClientGenerationProvider::Runway, "runway"),
];
for (variant, s) in all {
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
@@ -25,8 +25,6 @@ pub enum ApiClientVideoModel {
Seedance2p0Fast,
Seedance2p0BytePlus,
Seedance2p0BytePlusFast,
Seedance2p0Ultra,
Seedance2p0UltraFast,
Seedance2p0BytePlusUltra,
Seedance2p0BytePlusUltraFast,
Seedance2p0Mini,
@@ -65,8 +63,6 @@ impl From<String> for ApiClientVideoModel {
"seedance_2p0_fast" => Self::Seedance2p0Fast,
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
"seedance_2p0_u" => Self::Seedance2p0Ultra,
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
"seedance_2p0_mini" => Self::Seedance2p0Mini,
@@ -106,8 +102,6 @@ impl From<ApiClientVideoModel> for String {
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
@@ -165,8 +159,6 @@ mod tests {
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
@@ -22,7 +22,7 @@ artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
grok_api_client.workspace = true
seedance2pro_client.workspace = true
kinovi_web_client.workspace = true
tokens.workspace = true
url_utils.workspace = true
worldlabs_api_client.workspace = true
@@ -8,6 +8,6 @@ pub enum RouterProvider {
Fal,
GmiCloud,
GrokApi,
Seedance2Pro,
KinoviWeb,
WorldLabs,
}
@@ -14,6 +14,12 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "flux_3")]
Flux3,
#[serde(rename = "flux_3_draft")]
Flux3Draft,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -35,6 +41,15 @@ pub enum RouterVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -53,11 +68,7 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
Seedance2p0BytePlusUltra,
@@ -74,6 +85,15 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bpu_mini")]
Seedance2p0BytePlusUltraMini,
#[serde(rename = "seedance_2p5_preview")]
Seedance2p5Preview,
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -133,6 +153,25 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn seedance_2p5_preview_serializes() {
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
}
#[test]
fn seedance_2p5_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Seedance2p5, "seedance_2p5");
}
#[test]
fn seedance_2p5_ultra_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Seedance2p5Ultra, "seedance_2p5_u");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
@@ -141,4 +180,27 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn flux_3_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::Flux3, "flux_3");
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
}
#[test]
fn minimax_h3_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3");
}
#[test]
fn minimax_h3_turbo_and_ultra_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::MinimaxH3Turbo, "minimax_h3_turbo");
assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
}
}
@@ -7,5 +7,5 @@ pub mod router_client;
pub mod router_fal_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_seedance2pro_client;
pub mod router_kinovi_web_client;
pub mod router_worldlabs_client;
@@ -2,7 +2,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,7 +11,7 @@ pub struct MultiRouterClient {
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
pub(crate) kinovi_web_client: Option<RouterKinoviWebClient>,
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -36,9 +36,9 @@ impl MultiRouterClient {
.ok_or(ClientError::ClientNotConfigured(ClientType::GrokApi))
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
self.kinovi_web_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::KinoviWeb))
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
pub struct MultiRouterClientBuilder {
@@ -11,7 +11,7 @@ pub struct MultiRouterClientBuilder {
fal_client: Option<RouterFalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
kinovi_web_client: Option<RouterKinoviWebClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -22,7 +22,7 @@ impl MultiRouterClientBuilder {
fal_client: None,
gmicloud_client: None,
grok_api_client: None,
seedance2pro_client: None,
kinovi_web_client: None,
worldlabs_client: None,
}
}
@@ -47,8 +47,8 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_seedance2pro_client(mut self, client: RouterSeedance2ProClient) -> Self {
self.seedance2pro_client = Some(client);
pub fn set_kinovi_web_client(mut self, client: RouterKinoviWebClient) -> Self {
self.kinovi_web_client = Some(client);
self
}
@@ -63,7 +63,7 @@ impl MultiRouterClientBuilder {
fal_client: self.fal_client,
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
seedance2pro_client: self.seedance2pro_client,
kinovi_web_client: self.kinovi_web_client,
worldlabs_client: self.worldlabs_client,
}
}
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -13,7 +13,7 @@ pub enum RouterClient {
Fal(RouterFalClient),
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Seedance2Pro(RouterSeedance2ProClient),
KinoviWeb(RouterKinoviWebClient),
WorldLabs(RouterWorldLabsClient),
}
@@ -50,11 +50,11 @@ impl RouterClient {
}
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
match self {
RouterClient::Seedance2Pro(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_seedance2pro_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
RouterClient::KinoviWeb(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_kinovi_web_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::KinoviWeb)),
}
}
@@ -0,0 +1,11 @@
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
pub struct RouterKinoviWebClient {
pub(crate) session: KinoviWebSession,
}
impl RouterKinoviWebClient {
pub fn new(session: KinoviWebSession) -> Self {
RouterKinoviWebClient { session }
}
}
@@ -1,11 +0,0 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
pub struct RouterSeedance2ProClient {
pub(crate) session: Seedance2ProSession,
}
impl RouterSeedance2ProClient {
pub fn new(session: Seedance2ProSession) -> Self {
RouterSeedance2ProClient { session }
}
}
@@ -2,8 +2,8 @@ use crate::errors::client_error::ClientError;
use crate::errors::download_error::DownloadError;
use crate::errors::provider_error::ProviderError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use kinovi_web_client::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::api_error::ApiError;
@@ -65,7 +65,7 @@ impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
let is_billing_error = match &error {
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
ProviderError::Seedance2Pro(Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::BillingError { .. })) => true,
ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true,
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
_ => false,
};
@@ -9,7 +9,7 @@ pub enum ClientType {
Fal,
GmiCloud,
GrokApi,
Seedance2Pro,
KinoviWeb,
WorldLabs,
}
@@ -20,7 +20,7 @@ impl Display for ClientType {
Self::Fal => write!(f, "Fal"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
Self::KinoviWeb => write!(f, "KinoviWeb"),
Self::WorldLabs => write!(f, "WorldLabs"),
}
}
@@ -47,8 +47,8 @@ pub enum ClientError {
/// Fal only accepts image URLs for image inputs, not media tokens.
FalOnlySupportsUrls,
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// KinoviWeb only accepts URLs for media inputs, not media tokens.
KinoviWebOnlySupportsUrls,
/// The Fal endpoint requires a webhook URL but the caller built the client
/// in polling/queue mode (no webhook URL). Returned by webhook-only
@@ -91,8 +91,8 @@ impl Display for ClientError {
Self::FalOnlySupportsUrls => {
write!(f, "Fal only supports image URLs for image inputs, not media tokens")
}
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
Self::KinoviWebOnlySupportsUrls => {
write!(f, "KinoviWeb only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::WebhookUrlRequired => {
write!(f, "This Fal endpoint only supports webhook dispatch; the caller built RouterFalClient in polling-only mode (no webhook URL)")
@@ -4,7 +4,7 @@ use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
#[derive(Debug)]
@@ -13,7 +13,7 @@ pub enum ProviderError {
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Grok(GrokError),
Seedance2Pro(Seedance2ProError),
KinoviWeb(KinoviWebError),
WorldLabs(WorldLabsError),
}
@@ -26,7 +26,7 @@ impl Display for ProviderError {
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
Self::KinoviWeb(e) => write!(f, "KinoviWeb provider error: {}", e),
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
}
}
@@ -56,9 +56,9 @@ impl From<GrokError> for ProviderError {
}
}
impl From<Seedance2ProError> for ProviderError {
fn from(error: Seedance2ProError) -> Self {
Self::Seedance2Pro(error)
impl From<KinoviWebError> for ProviderError {
fn from(error: KinoviWebError) -> Self {
Self::KinoviWeb(error)
}
}
@@ -26,8 +26,8 @@ impl AudioGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
}
}
@@ -4,7 +4,7 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -19,9 +19,9 @@ pub struct AudioGenerationDraftContext<'a> {
}
impl <'a> AudioGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
client.get_kinovi_web_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
}
@@ -48,10 +48,10 @@ impl AudioGenerationRequest {
Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft,
Self::FalSeedAudio1p0(_) => RouterProvider::Fal,
Self::KinoviSunoMusic(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSounds(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoMusic(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSounds(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
}
}
@@ -100,19 +100,19 @@ impl AudioGenerationRequest {
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoMusic(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoRemix(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSounds(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSample(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
},
}
@@ -116,10 +116,10 @@ impl GenerateAudioRequestBuilder {
// Fal
(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self),
// Kinovi
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -202,7 +202,7 @@ mod tests {
#[test]
fn kinovi_suno_music_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic).build2().expect("build");
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_))
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn kinovi_suno_sounds_dispatches_to_request() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds).build2().expect("build");
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_))
@@ -220,7 +220,7 @@ mod tests {
#[test]
fn kinovi_suno_remix_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_))
@@ -229,7 +229,7 @@ mod tests {
#[test]
fn kinovi_suno_sample_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_))
@@ -242,7 +242,7 @@ mod tests {
#[test]
fn kinovi_seed_audio_1p0_is_unsupported() {
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SeedAudio1p0).build2();
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SeedAudio1p0).build2();
assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))));
}
@@ -10,7 +10,7 @@ pub struct ArtcraftAudioResponsePayload {
}
#[derive(Clone, Debug)]
pub struct Seedance2proAudioResponsePayload {
pub struct KinoviWebAudioResponsePayload {
pub order_id: String,
pub task_id: String,
}
@@ -37,7 +37,7 @@ pub struct FalAudioResponsePayload {
#[derive(Clone, Debug)]
pub enum GenerateAudioResponse {
Artcraft(ArtcraftAudioResponsePayload),
Seedance2Pro(Seedance2proAudioResponsePayload),
KinoviWeb(KinoviWebAudioResponsePayload),
Fal(FalAudioResponsePayload),
}
@@ -49,9 +49,9 @@ impl GenerateAudioResponse {
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proAudioResponsePayload> {
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebAudioResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
Self::KinoviWeb(p) => Some(p.clone()),
_ => None,
}
}
@@ -5,14 +5,14 @@
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
//! video provider (`generate_video::providers::kinovi::upload`).
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_kinovi_web;
/// A single unresolved audio reference: either an ArtCraft/public URL or a
/// media file token that resolves to one via the draft context's map.
@@ -55,7 +55,7 @@ pub(crate) fn require_single_audio_ref(
/// Resolve a single audio reference to a source URL and re-upload it to the
/// Kinovi CDN, returning the Kinovi CDN URL.
pub(crate) async fn resolve_and_upload_audio_ref(
session: &Seedance2ProSession,
session: &KinoviWebSession,
audio_ref: &SingleAudioRef,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<String, ArtcraftRouterError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
})?
}
};
upload_to_seedance2pro(session, &source_url).await
upload_to_kinovi_web(session, &source_url, None).await
}
#[cfg(test)]
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
@@ -119,7 +119,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a song about corgis".to_string()),
style_prompt: Some("sea shanty".to_string()),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
@@ -13,7 +13,7 @@ impl KinoviSunoMusicCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoMusicRequest {
prompt: String::new(),
@@ -54,7 +54,7 @@ mod tests {
fn cost_from_built_request_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a song".to_string()),
..Default::default()
};
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_music::{
use kinovi_web_client::generate::audio::generate_suno_music::{
generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoMusicRequestState {
}
impl KinoviSunoMusicRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoMusicArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoMusicRequestState {
let response = generate_suno_music(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -38,12 +38,12 @@ impl KinoviSunoMusicRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
style_prompt: Some("Sea shanty, folk".to_string()),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -69,7 +69,7 @@ mod tests {
is_instrumental: Some(true),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -78,20 +78,20 @@ mod tests {
fn suno_music_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -168,7 +168,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -20,7 +20,7 @@ impl KinoviSunoRemixCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoRemixRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -26,7 +26,7 @@ impl KinoviSunoRemixDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoRemixRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_remix::{
use kinovi_web_client::generate::audio::generate_suno_remix::{
generate_suno_remix, GenerateSunoRemixArgs, GenerateSunoRemixRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoRemixRequestState {
}
impl KinoviSunoRemixRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoRemixArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoRemixRequestState {
let response = generate_suno_remix(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoRemixRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn remix_uploaded_audio() {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec![audio_url])),
@@ -73,17 +73,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -112,7 +112,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -162,7 +162,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("mystical RPG adventure".to_string()),
style_prompt: Some("fantasy video game".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
@@ -18,7 +18,7 @@ impl KinoviSunoSampleCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSampleRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
@@ -34,7 +34,7 @@ impl KinoviSunoSampleDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoSampleRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_sample::{
use kinovi_web_client::generate::audio::generate_suno_sample::{
generate_suno_sample, GenerateSunoSampleArgs, GenerateSunoSampleRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoSampleRequestState {
}
impl KinoviSunoSampleRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSampleArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoSampleRequestState {
let response = generate_suno_sample(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoSampleRequestState {
#[cfg(test)]
mod tests {
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn sample_instrumental() {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
@@ -74,17 +74,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -113,7 +113,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -1,5 +1,5 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -201,7 +201,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("rain sound effects".to_string()),
..Default::default()
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -15,7 +15,7 @@ impl KinoviSunoSoundsCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by seedance2pro_client's binding — the router just
// Cost math is owned by kinovi_web_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSoundRequest {
prompt: String::new(),
@@ -57,7 +57,7 @@ mod tests {
fn cost_from_built_request_is_two_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("thunder".to_string()),
is_loopable: Some(true),
bpm: Some(60),
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::audio::generate_suno_sound::{
use kinovi_web_client::generate::audio::generate_suno_sound::{
generate_suno_sound, GenerateSunoSoundArgs, GenerateSunoSoundRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, Seedance2proAudioResponsePayload,
GenerateAudioResponse, KinoviWebAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoSoundsRequestState {
}
impl KinoviSunoSoundsRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSoundArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoSoundsRequestState {
let response = generate_suno_sound(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,12 +39,12 @@ impl KinoviSunoSoundsRequestState {
#[cfg(test)]
mod tests {
use enums::common::generation::common_musical_key::CommonMusicalKey;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
prompt: Some("A heavy castle door creaking open".to_string()),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -70,7 +70,7 @@ mod tests {
musical_key: Some(CommonMusicalKey::AMinor),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -79,20 +79,20 @@ mod tests {
fn suno_sounds_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
fn get_kinovi_web_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_seedance2pro_client();
let client = get_kinovi_web_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::Seedance2Pro(p) => {
GenerateAudioResponse::KinoviWeb(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -146,12 +146,12 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / Seedance2Pro (Midjourney + Seedream)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
// Kinovi / KinoviWeb (Midjourney + Seedream)
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -25,11 +25,11 @@ pub struct FalImageResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney
/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the
/// Response from the KinoviWeb/Kinovi provider (used for Midjourney
/// image generation). Mirrors `KinoviWebVideoResponsePayload` on the
/// video side.
#[derive(Clone, Debug)]
pub struct Seedance2proImageResponsePayload {
pub struct KinoviWebImageResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
@@ -40,7 +40,7 @@ pub struct Seedance2proImageResponsePayload {
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
Seedance2Pro(Seedance2proImageResponsePayload),
KinoviWeb(KinoviWebImageResponsePayload),
}
impl GenerateImageResponse {
@@ -58,9 +58,9 @@ impl GenerateImageResponse {
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proImageResponsePayload> {
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebImageResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
Self::KinoviWeb(p) => Some(p.clone()),
_ => None,
}
}
@@ -17,7 +17,7 @@ use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft:
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only the Kinovi models need this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// supplies reference images, we must upload them to the KinoviWeb
/// CDN before sending the request.
#[derive(Clone, Debug)]
pub enum ImageGenerationDraftRequest {
@@ -30,10 +30,10 @@ pub enum ImageGenerationDraftRequest {
impl ImageGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
}
}
@@ -4,21 +4,21 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Context passed when finalizing an image draft into a concrete request.
///
/// Drafts only exist for providers that need to upload assets before
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
/// sending the request. Today that's the KinoviWeb/Kinovi pipeline
/// (used for Midjourney image generation with reference images); other
/// providers — Artcraft, Fal — accept image URLs directly and skip the
/// draft phase entirely.
#[derive(Clone, Default)]
pub struct ImageGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require
/// authenticated calls (e.g. Seedance2Pro/Kinovi uploads).
/// authenticated calls (e.g. KinoviWeb/Kinovi uploads).
pub client: Option<&'a RouterClient>,
/// Optional: a map of Media File Tokens to their ArtCraft URLs.
@@ -29,9 +29,9 @@ pub struct ImageGenerationDraftContext<'a> {
}
impl<'a> ImageGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)
}
}
@@ -123,7 +123,7 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
// ── Kinovi / KinoviWeb provider (Midjourney + Seedream image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
@@ -170,10 +170,10 @@ impl ImageGenerationRequest {
Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal,
Self::FalFlux2LoraAngles(_) => RouterProvider::Fal,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
}
}
@@ -416,24 +416,24 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
// ── Kinovi / KinoviWeb (Midjourney + Seedream) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney7Niji(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney8(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
let seedance_client = client.get_kinovi_web_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
@@ -22,14 +22,15 @@ use crate::generate::generate_image::generate_image_request_builder::GenerateIma
/// Per-model build functions are expected to validate model-specific
/// constraints (e.g. unsupported aspect ratios) BEFORE calling this helper.
/// Validation that's identical across every Artcraft image model — batch count
/// (1..=4) and image_inputs shape — lives here.
/// (1..=[`max_batch_count`]) and image_inputs shape — lives here.
pub fn build_artcraft_omni_image_request(
builder: GenerateImageRequestBuilder,
model: CommonImageModelEnum,
) -> Result<OmniGenImageCostAndGenerateRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let image_batch_count = plan_batch_count(builder.image_batch_count, strategy)?;
let image_batch_count =
plan_batch_count_with_max(builder.image_batch_count, strategy, max_batch_count(model))?;
let image_media_tokens = resolve_image_list_ref(builder.image_inputs.clone())?;
let aspect_ratio = builder.aspect_ratio.map(to_aspect_ratio_enum);
@@ -52,26 +53,47 @@ pub fn build_artcraft_omni_image_request(
})
}
/// The largest batch each Artcraft image model accepts. Seedream 5.0 Pro and
/// Ultra accept batches of up to 8; every other model caps at 4.
pub fn max_batch_count(model: CommonImageModelEnum) -> u16 {
match model {
CommonImageModelEnum::Seedream5p0Pro | CommonImageModelEnum::Seedream5p0ProUltra => 8,
_ => 4,
}
}
/// Validate batch count (1..=4). Mirrors the v1 plan-level validation that
/// every Artcraft image model performs.
pub fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
plan_batch_count_with_max(image_batch_count, strategy, 4)
}
/// Validate batch count (1..=`max`), clamping or erroring per the mitigation
/// strategy when the request exceeds the model's cap.
pub fn plan_batch_count_with_max(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
max: u16,
) -> Result<u16, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
if count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
}
if count <= max {
return Ok(count);
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(max),
}
}
@@ -2,8 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState;
/// Cost state for Artcraft Flux 2 LoRA Angles. Pricing mirrors v1
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image
/// (Artcraft markup over Fal's 2¢/image base rate).
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image.
#[derive(Clone, Debug)]
pub struct ArtcraftFlux2LoraAnglesCostState {
pub num_images: u16,
@@ -16,7 +16,9 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = 4u64 * self.num_images as u64;
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
// whole cents across the batch).
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -59,14 +61,14 @@ mod tests {
}
#[test]
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
#[test]
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
#[test]
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
#[test]
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
}
@@ -148,7 +148,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -169,7 +169,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -50,7 +50,7 @@ pub fn build_kinovi_midjourney_7(
}
// Image inputs present → must go through the draft phase so we can
// upload each image to the Seedance2Pro CDN before sending.
// upload each image to the KinoviWeb CDN before sending.
let draft = KinoviMidjourney7DraftState {
prompt,
aspect_ratio,
@@ -159,7 +159,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -178,7 +178,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
@@ -291,7 +291,7 @@ mod tests {
mod aspect_ratio_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
fn aspect_ratio_for(input: Option<RouterAspectRatio>) -> Ar {
let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() };
@@ -334,7 +334,7 @@ mod tests {
mod quality_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
fn quality_for(input: Option<RouterQuality>) -> Option<Q> {
let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() };
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -171,7 +171,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("test".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7DraftState {
pub quality: Option<GenerateMidjourneyV7Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
/// Image references that still need to be uploaded to Seedance2Pro CDN
/// Image references that still need to be uploaded to KinoviWeb CDN
/// before the request is sendable. `None` once `to_request()` has run.
pub unhandled_request_state: Option<KinoviMidjourney7RemainingItems>,
}
@@ -32,7 +32,7 @@ impl KinoviMidjourney7DraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -63,10 +63,10 @@ impl KinoviMidjourney7DraftState {
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
// Note: most behavioral tests live in `build.rs` and `cost.rs`. The
// `to_request()` method requires a live Seedance2Pro client to upload
// `to_request()` method requires a live KinoviWeb client to upload
// images, so its end-to-end behavior is exercised by the `request.rs`
// #[ignore]'d integration tests rather than unit-tested here.
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
use kinovi_web_client::generate::image::generate_midjourney_v7::{
generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -18,7 +18,7 @@ pub struct KinoviMidjourney7RequestState {
impl KinoviMidjourney7RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7Args {
session: &client.session,
@@ -28,9 +28,9 @@ impl KinoviMidjourney7RequestState {
let response = generate_midjourney_v7(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -43,7 +43,7 @@ impl KinoviMidjourney7RequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -52,7 +52,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
@@ -64,7 +64,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()),
image_inputs: None,
resolution: None,
@@ -82,9 +82,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -98,9 +98,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -135,7 +135,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -166,7 +166,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a magical anime forest scene".to_string()),
image_inputs: None,
resolution: None,
@@ -235,7 +235,7 @@ mod tests {
#[test]
fn aspect_ratio_passes_through_to_niji_enum() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
let builder = GenerateImageRequestBuilder {
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
..base_builder()
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn quality_high_maps_to_full() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
let builder = GenerateImageRequestBuilder {
quality: Some(RouterQuality::High),
..base_builder()
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -29,7 +29,7 @@ impl KinoviMidjourney7NijiDraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7NijiRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
GenerateImageResponse, KinoviWebImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7NijiRequestState {
impl KinoviMidjourney7NijiRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
client: &RouterKinoviWebClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7NijiArgs {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviMidjourney7NijiRequestState {
let response = generate_midjourney_v7_niji(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviMidjourney7NijiRequestState {
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -51,7 +51,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -62,7 +62,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".to_string()),
image_inputs: None,
resolution: None,
@@ -80,9 +80,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
}
#[tokio::test]
@@ -96,9 +96,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7-niji t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -133,7 +133,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
println!("v7-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
use kinovi_web_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use kinovi_web_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -164,7 +164,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
provider: RouterProvider::KinoviWeb,
prompt: Some("a spooky horror scene".to_string()),
image_inputs: None,
resolution: None,

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