Compare commits

...
Author SHA1 Message Date
Tanish Manku b9ee643bf6 Stop the bootstrap scripts claiming the dev webapp targets the local backend
It never has: setDevelopment() is commented out in main.tsx, so the dev server
falls through to its /v1 proxy at api.storyteller.ai. Both bootstrap epilogues
and the seed script now say so and print the VITE_DEV_API_HOST needed to
actually reach the local backend.
2026-08-03 17:24:39 -07:00
Tanish Manku 07e9a35044 Add portable local MySQL/Redis bootstrap scripts for backend dev
Brings up a fully local storyteller-web: portable MySQL 8.4 and Redis under a
gitignored .devstack/ (plain user processes -- no admin, no services, no WSL,
no Docker), all 281 migrations, role/badge seeds, and a demo login with
credits. Windows and Linux variants, plus a doctor that reports what is and
isn't healthy.

Scripts and docs only. Nothing here is compiled into any binary, and no
application behaviour changes: the backend it starts is the one that ships.

Integrations that cannot be provided locally -- object storage, generation
providers, email -- get placeholder credentials, so those endpoints fail when
exercised. Swap in a real dev credential for the one you are working on. The
doc records exactly which areas that leaves working.

An earlier revision closed those gaps with development-only behaviour inside
storyteller-web (DEV_FAKE_GENERATION, a filesystem bucket mode, a CDN_BASE_URL
override). That is deliberately not here: a switch that exists in the shipped
binary can be flipped. Frontend work that wants a populated, working stack
should use frontend/apps/fake-storyteller-web instead.
2026-08-03 14:41:14 -07:00
hanashi 0eddb659fa fixup 3d on admin dash, add databox (#1823) 2026-07-30 20:20:44 -04:00
hanashi 17c12836ad seedream 5 plus (#1822) 2026-07-30 18:25:19 -04:00
BFlat 7654e95636 Desktop: drag & drop files onto the promptbox on create pages (#1821)
### What
- Drag files onto the promptbox on the Image, Video, and Audio create
pages to add them as references - same look and behavior as the webapp
- On those pages the promptbox is now the only drop target; dropping
elsewhere does nothing
- Every other page keeps the existing upload-modal behavior

### Notes
- The webapp's drop code doesn't work as-is on desktop - Tauri
intercepts file drags before the page sees them, so desktop needed its
own path
- Both now share one component, so the two apps stay in sync going
forward
- Model limits still apply: each box only accepts the reference types
its model supports, and existing slot/duration caps are unchanged
2026-07-30 22:37:34 +07:00
BFlat 1bb31f42d7 Bug fix: hand-typed mentions (#1819)
- **Hand-typed mentions always take the character's exact name** -
typing `@person` for a character named `Person` now converts to
`@Person` as you type, so the tag binds correctly instead of silently
failing at generation time.
2026-07-30 08:05:44 +07:00
BFlat bc4b3a6c1f Bug fix: mention textarea & reference deck (#1818)
- **Prompt input no longer freezes at a character tag** - the caret
could get stuck inside a chip (e.g. after deleting the space behind it),
blocking all typing.
- **Reference add-menu no longer hides behind the sidebar** - it now
renders above everything instead of getting clipped by the promptbox
layers.
- **Character autocomplete is scrollable in focus mode** - the
fullscreen prompt's scroll lock was blocking it; same fix for the chip
menu's Replace list.
2026-07-30 07:43:40 +07:00
BFlat 13b5e7b5c2 Mention textarea focus & dropdown fixes (#1817)
- Fix popovers (model selector etc.) closing instantly when the mention
textarea was focused
- Fix mention autocomplete not clickable in prompt focus mode
- Fix chip "Replace" reopening the autocomplete - now confirms
immediately
- Fix autocomplete appearing at top-left of screen when caret is next to
a leading chip
- Don't open the autocomplete when clicking the caret onto an
already-confirmed chip
2026-07-29 23:09:26 +07:00
BFlat 4ff5bdf475 Webapp: mobile audio ref fixes (#1816)
- **Fixed: picking an audio reference on mobile opened the photo/video
picker.** iPhones don't have an audio library, so the browser fell back
to Photos - the only thing you could "attach as audio" was a video, and
it silently uploaded as an audio ref. Audio pickers now open the Files
app filtered to real audio files (MP3, WAV, M4A, FLAC…).
- **Non-audio files are now rejected with a clear error toast** instead
of being silently attached (also covers drag & drop and Android's "any
file" picker).
- **Fixed: limit warnings never appeared in the web app.** Errors like
"Total audio duration cannot exceed 15s" from shared components were
firing into a toast system the web app doesn't render. They're now
routed to the app's own toasts, so length/limit errors actually show up.
- Applies to the create video, create audio, and desktop reference deck
flows
2026-07-29 22:17:04 +07:00
BFlat e1b16cf2de Character mention chips + mention fixes (#1815)
### New stuff
- Character `@mentions` in the video promptbox now show as **chips with
a thumbnail**, so you can see exactly which character is attached to the
prompt
- Clicking a chip opens a menu to **Replace** (pick another character),
**Preview** (view their image), or **Remove** the mention
- Chips behave like a single unit - one backspace deletes the whole
mention, and removing/replacing can be undone with Ctrl+Z
- Copying prompt text still gives clean plain text (`@name ...`), and
pasting it back restores the chips
- When two characters share a name, the one you actually picked is now
the one that gets sent

### Fixes
- Mobile: the `@` autocomplete no longer goes off-screen - it stays
inside the viewport, flips below the caret when needed, and taps select
reliably
- Focus mode: the autocomplete no longer pushes the prompt sideways /
causes overflow
- Enter-to-generate now works while the mention editor is active on the
webapp
2026-07-29 21:43:02 +07:00
BFlat 2d7bf65a23 Bug fix: created characters token (#1813)
- Create flow stored `inference_job_token` as the `character_token`, so
mentioning
a new character sent a bogus token - backend drops it and name-matches
the
  raw `@name` against stale characters.
- Don't add pending characters to the mention store; they aren't
generatable yet.
- Keep the pending poll running after the modal closes and sync real
tokens into
  the store once the character is active.
- Remove unused `addCharacter` store action.
- Same fix in all three copies: webapp, website, shared `promptbox` lib.
2026-07-28 22:03:30 +07:00
Brandon Thomas 6ee5518c55 api keys for everyone (#1811) 2026-07-27 14:55:54 -04:00
Brandon Thomas f708402e8a grab billing data for reports (#1810) 2026-07-27 10:03:13 -04:00
BFlat ffa86f0ba8 Webapp: Video Frame Extractor (#1809)
Ports the Video Frame Extractor from the desktop app to the webapp at
`/frame-extractor`, with a reworked UI.

### What's new
- New Frame Extractor page: upload a video, pick one from your library,
or drag and drop
- Scrub to any moment and capture the exact frame at the playhead with
one click
- Frame stepping buttons and keyboard shortcuts (arrows to step, Space
to play/pause, C or Enter to capture)
- Burst capture: grab a sequence of frames to find the sharpest one,
with progress and cancel
- Each captured frame can be used as an image reference, animated into a
video, saved to the library, or downloaded
- Save all and clear actions for captured frames

### Integrations
- "Extract frames" action on library videos (selection bar and lightbox)
opens the extractor with that video preloaded
- Sends frames straight into the create-image / create-video reference
deck, same flow as "Send to prompt"
- New sidebar entry (purple NEW badge) and home page card
- Page is public; saving and references prompt sign-up when logged out
2026-07-27 18:52:47 +07:00
BFlat b741cdd76c Webapp: send library media to the create-image / create-video promptbox (#1807)
- Adds a **Send to prompt** action to the library's multi-select bar
with two
  targets: **Create image** and **Create video**
- Selected images are attached as reference images on the target page
without
touching the user's typed prompt or settings; selection is cleared and
the
  app navigates to the create page
- Selected videos can be sent to the video prompt as reference videos;
models
without video-reference support (only Seedance has it) get an
explanatory
  toast instead of a silent drop
- Media travels via `pendingRefImages` / `pendingRefVideos` store slots
(mirroring the existing `pendingRecreate` pattern) and is merged on the
  receiving page once its model list is loaded, so the **real per-model
reference caps** apply - fixes silent no-ops when refs were already
attached
- Merging dedupes by media token, clamps to the model cap, and toasts
partial
  adds ("Added 3 of 5 …"), full decks, and unsupported models
- Keyframe-only video models take the first sent image as the start
frame;
reference-capable models switch into reference mode so the deck is
visible
- DRY: shared `galleryItemToRefImage` mapper (reused by the create-image
library picker) and `buildVideoRefsToAdd` (reused by the video-ref
picker,
  duration probing + total-duration cap)
- Bar UX: option list adapts to the selection (video-only hides Create
image),
  icon-only button on phones to avoid overflowing the bar
2026-07-25 15:07:21 +07:00
bflatastic 9fbbfb5877 Webapp: copy paste image references into promptbox 2026-07-25 09:10:01 +07:00
BFlat a49426b77d Webapp/desktop: multi-select batch download on the create-image page (#1806)
- Multi-select and batch download also enabled on the create-image page
(webapp + desktop); zip renamed to artcraft-media-*.zip
2026-07-23 20:37:40 +07:00
BFlat 9f28c525ec Webapp/desktop: batch download video results, refresh credits after failed generations (#1801)
## Changes

**Batch download**
- Add multi-select mode to the create-video results feed (webapp +
desktop) with a "Select" toggle in the topbar, checkboxes on completed
items, and a floating download bar
- Webapp: "Download" bundles selected videos into a single .zip
client-side (new jszip dependency, concurrent fetches, progress and
partial-failure toasts)
- Desktop: "Download" prompts for a folder and saves each selected video
as its own file via native Tauri save, with progress toasts
- Library: add a Download (zip) button to the existing bulk selection
bar
- Shared `ui-generation-list` lib gains a selection store, select
toggle, action bar, and optional selection props on the grid/list views
(existing hosts unaffected)

**Credits refresh on failed generations**
- Webapp: dispatch a credits refresh when the task queue or generation
feed observes a newly failed job (immediately and again after 2.5s so
server-side refunds settle)
- Desktop: also refresh credits when the 5s task poll discovers a
failure that the push event missed, and listen for `credits-change` in
the TopBar

**UI fixes**
- Selection download bar always sits above the promptbox regardless of
its size
- Mobile Generate/History header no longer overlaps the gallery toggles
(flex layout instead of absolute positioning)
- Reference deck top-aligns in the expanded prompt box instead of
centering
- "Play videos with sound" setting now defaults to on
2026-07-23 19:54:12 +07:00
BFlat d4239a3516 Webapp: drag & drop files onto the prompt box to add references (#1800)
- Drop images, videos, or audio anywhere on the prompt box - files are
sorted into the right reference type based on what the current model
supports
- Drag-over overlay shows which file kinds are accepted; turns red when
the hovered files aren't usable
- Video keyframe mode: dropped images fill the start frame, then the end
frame
- Unsupported kinds, full slots, and non-media files get a clear toast
instead of failing silently
- Reuses the existing `useDeckMedia` upload pipelines, so limits and
error handling match the "+" pickers
2026-07-23 03:29:49 +07:00
BFlat 109d7081db Fix: silent failures when submitting a generation (recreate "does nothing" bug) (#1799)
Users reported that after using **Recreate**, pressing the generate
arrow did nothing at all (no error, no card, no spinner). Root cause:
several failure paths gave zero feedback, so a rejected request looked
like a dead button or an outage.

### Changes

- **Show real error messages.** The API layer now keeps the server's
error message (e.g. "Image is required") instead of throwing it away, so
users see why a generation was rejected.
- **Toast on every failed submit.** Image and video pages (webapp +
website) now show an error toast when a generation fails to start.
Previously, most failures were recorded invisibly and never shown.
- **Out of credits feedback on the website.** 402 responses now show a
toast (webapp keeps its upgrade modal).
- **Network errors are announced** instead of failing silently.
- **No more silent submit blocks.** Cases that used to quietly ignore
the click now explain themselves:
  - Model list still loading
- Model needs a starting image (website; also un-disabled the button so
clicking gives feedback)
- **Recreate model fallback is announced.** If the original generation's
model is no longer available, recreate now tells the user it switched to
the default model instead of silently swapping.
2026-07-22 13:27:06 +07:00
BFlat 5faa94a9dd Webapp/desktop: video preview and lightbox sound settings (#1798) 2026-07-22 08:17:19 +07:00
Tanish Manku 9c83c20c46 Frontend: Moodboard persistence (#1793) 2026-07-20 19:08:56 -07:00
Tanish Mankuandbflatastic cb4374ea70 Frontend: video editor persistence (#1792)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-07-20 18:01:24 -07:00
BFlat eb59e7fcd7 Webapp: move audio/3D reference inputs into the prompt box reference deck (#1795)
## Summary

Compacts the legacy reference bands above the prompt box (audio ref row,
multi-view/input-mesh row, reference video row) into the reference deck
design already used on the image and video pages. Mobile forms keep
their existing rows.

## Changes

- **Create Audio** - audio tracks are now deck cards (playable tile,
duration badge) with Upload / From library in the deck's "+" menu; band
removed. Mutual-exclusion and "Audio track required" behavior unchanged.
- **Create 3D World** - guide video is now a deck card; store reworked
to the shared `RefVideo[]` shape.
- **Create 3D Object** - Front/Back/Left/Right are always-visible
labeled slot cards next to the image-ref deck (same design as First/Last
frame on video), plus a "Mesh" slot card for mesh-to-mesh models. Upload
logic deduplicated into `mesh-upload.ts`.
- **Webapp PromptBox** - removed the `mediaReferenceRow` band prop; deck
video/audio groups gate on `videoRefsSupported`/`audioRefsSupported` (no
longer video-page-only); new `referenceSlots` prop; `maxImages: 0`
blocks image uploads on pages whose model takes no image refs.
- **Deck lib** - exported `DeckSlotCard`; new `mesh` card kind;
`sortable: false` for fixed slots; non-finite duration caps hide the
seconds limit; collapsed fan width now tracks card count, so a single
ref sits snug against the prompt (multi-ref unchanged).
2026-07-20 21:12:01 +07:00
BFlat 185bf835a1 Desktop: tags in the gallery modal (#1794)
- Tags now work in the desktop app - no separate build, it all comes
through the shared gallery/lightbox components
- Open any item in the gallery → edit its tags in the details panel
(same chip editor as the webapp, with autocomplete)
- New "Tags" section in the gallery sidebar: your 5 most-used tags + an
"All tags" entry
- Click a tag to see all media with that tag in the normal grid
- "All tags" opens a word cloud (bigger = used more), click a word to
jump in
- Webapp sidebar now also caps at 5 tags with a "See all tags" link
- The tag editor is now one shared component used by both apps instead
of two copies
2026-07-17 20:44:49 +07:00
BFlat 65463ae14c Update character posing video link in README 2026-07-16 22:40:51 +07:00
BFlat fc61c6efd1 Update README.md character pose video 2026-07-16 22:34:47 +07:00
bflatastic 4788ee9a23 Website: character pose video swap 2026-07-16 21:53:00 +07:00
BFlat 77cee77258 Webapp: media tags (#1791)
- Tags editor in LightboxDetails (lightbox + /media/:id): chip input
with autocomplete over the user's existing tags, comma/paste splitting,
duplicate rejection, owner gating via session, and serialized+coalesced
SetMediaFileTags snapshot saves with optimistic chips and error revert
(components/tags/TagChipInput.tsx, TagsSection.tsx)
- Tag browser: "Tags" tab in the library with name/most-used sort, tag
view at /library/tag_… reusing the media grid, breadcrumb header with
rename (FolderNameDialog) and delete (confirm modal), tag context menu
- Sidebar: tags rows under the Assets group below folders, with
expand/collapse chevrons on both Folders (default open) and Tags
(default closed), animated via framer-motion; tag icons use violet to
distinguish them from folders
- New library-tags-store mirroring the folders store (cursor-paged media
caches, optimistic rename/delete with toast revert); shared item mappers
extracted to library-media-map.ts to avoid a circular import; media
deletion now purges tag caches; bulk-selection bar resolves thumbnails
from tag caches too
- Topbar breadcrumbs map /library/tags and tag views to "Tags"
- Tags show as word cloud
2026-07-15 21:57:21 +07:00
BFlat 9f69eb162a Webapp/Desktop: clear all button for promptboxes (#1790) 2026-07-15 19:49:21 +07:00
BFlat 46d05808cc Reference decks + new generate button for image/video promptboxes (#1783)
Redesigns how references are attached in the image and video
promptboxes, on both the desktop app and the webapp (desktop layout -
mobile is unchanged).

### Reference deck
- Replaces the image-toggle icon and the upload bands above the prompt
with a fanned stack of reference cards inside the promptbox, left of the
prompt text
- Hovering the deck expands it to show every attached ref - no
scrolling, it widens toward the end of the promptbox and wraps into more
rows as needed (ready for up to 50 refs for Seedance 2.5)
- Cards support drag-to-reorder (grab/grabbing cursors), hover remove,
name tooltips (Image 1, Video 2, …), upload progress spinners on
individual cards, and click-to-preview fullscreen
- A circular **+** overlaps the deck: hovering shows an add menu grouped
by type (Image / Video / Audio) with compact limits (e.g. `2/9`, `1/3 ·
8/15s`); clicking it opens a file picker that accepts any supported
media and sorts files by type automatically

### Video modes
- Keyframe mode: Start/End frame band replaced with compact "First frame
⇄ Last frame" cards with a swap button
- Reference mode renamed to **Omni reference** and is now the default;
images, videos, and audio all stack into one mixed deck

### Generate button
- Replaced the "Generate" pill with a circular arrow-up button, credit
cost shown beside it

### Under the hood
- New shared deck components live in `@storyteller/ui-promptbox` and are
used by both apps (previously the two promptbox implementations were
fully separate)
- New `GenerateIconButton` in `@storyteller/ui-button`; the old
`GenerateButton` stays for mobile
- Webapp mobile, audio/object/world pages, and the 2D/3D/Edit
promptboxes keep their existing UI
2026-07-14 21:08:15 +07:00
BFlat dbe38a588f Webapp: swap folders and unfoldered sidebar links (#1788) 2026-07-14 20:51:18 +07:00
Tanish Manku 58e2d08c76 Frontend: Endpoint migration and cleanup (#1787) 2026-07-13 22:44:41 -07:00
Tanish Manku f991eafd01 Frontend: mesh/splat classes and handling (#1786) 2026-07-13 22:42:41 -07:00
Tanish Manku 283b4ab04f Frontend: remove dead API client code targeting unregistered endpoints (#1785)
Deletes dormant legacy API surface (zero live callers, verified against
the full backend route table):

- VoiceDesignerApi, VoiceConversionApi, AnalyticsApi
(/v1/analytics/log_session was never registered) - both @storyteller/api
and the Tauri app's ApiManager copies, plus their tests
- PromptsApi/EngineApi: /v1/image_studio/prompt,
/v1/image_studio/session_jobs, /v1/jobs/session/{token} methods (routes
do not exist)
- UserBookmarksApi: broken-path list methods (/list/{user} and
/list/{et}/{etok}; backend shapes are /list/user/{u} and
/list/entity/{et}/{etok}) and the FakeYou-era scoped enums that only
they consumed
- Dashboard UsersApi: RequestPasswordReset/ResetPassword pointing at
removed /v1/request_password_reset + /v1/reset_password (live pages use
PasswordResetApi -> /v1/password_reset/* correctly)

Fixes WeightsApi cover-image path to the registered route:
/v1/weights/weight/{token}/cover_image (both copies).
2026-07-13 21:56:11 -07:00
Brandon Thomas 0003cad179 seedream5 (#1784) 2026-07-13 11:10:21 -04:00
Brandon Thomas 863bd1c4e1 Sort folders (#1782) 2026-07-12 18:13:04 -04:00
Brandon Thomas 79b7e31ec5 unfoldered navigation (frontend) (#1781) 2026-07-12 15:47:28 -04:00
Brandon Thomas eed865e57c cursor behavior fixes (#1780) 2026-07-12 15:28:34 -04:00
Brandon Thomas 348a603cae folderless endpoint (#1779) 2026-07-12 15:23:58 -04:00
Brandon Thomas 9898c6dbcf view meshes and splats in 3d modal (#1778) 2026-07-12 04:34:36 -04:00
Tanish Mankuandbflatastic e0047d9410 3D PageScene priming for scene builder (#1746)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-07-12 03:45:35 -04:00
Brandon Thomas a79ee6ad57 misc fixes for storyteller-web (#1777) 2026-07-12 03:21:23 -04:00
Brandon Thomas 2a9e71f5b8 fix pagination cursors (#1776) 2026-07-11 23:48:34 -04:00
Brandon Thomas 7dffc5fb5c list meshes and splats (#1774) 2026-07-12 09:32:35 +07:00
BFlat bc0174ac7a Webapp: video ref pick from library (#1775)
- The "+" button on the Video Ref row now shows options on hover:
**Upload** or **Pick from library** (on mobile it opens an "Add video"
sheet)
- Picking from library opens the gallery in select mode, filtered to
videos only
- Library videos respect the same limits as uploads (max count and total
duration, with the same error toasts)
- Reused the existing add-button component from the image row so all
reference rows look and behave the same
2026-07-12 09:12:14 +07:00
Brandon Thomas 9cc9d4b9cf Fix cover images (#1773) 2026-07-11 15:32:15 -04:00
Brandon Thomas dc5a4f8dd9 backup for cover images when absent 2026-07-10 17:14:13 -04:00
Brandon Thomas 11ddeb05b5 handle audio results (#1772) 2026-07-10 16:58:34 -04:00
455 changed files with 31009 additions and 5112 deletions
+5
View File
@@ -1,3 +1,8 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
+3
View File
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
@@ -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": "1c74e9c2dcc9205e2c9fe60a6de0828208e497e68560246e588092bafd03eb59"
}
@@ -0,0 +1,245 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"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_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"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": 4
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c"
}
@@ -0,0 +1,245 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"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_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"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,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a"
}
@@ -0,0 +1,223 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\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.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\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.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\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_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id < ?\nORDER BY m.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"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": 2,
"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": 3,
"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": 4,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 14,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 15,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 17,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 18,
"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": 4
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "73540435892b7b4dbc11c6c63712abdb18fd8b3db2bcd56b8518c968b8169de7"
}
@@ -0,0 +1,223 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.id as `id: u64`,\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.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n users.username as maybe_creator_username,\n users.display_name as maybe_creator_display_name,\n users.email_gravatar_hash as maybe_creator_gravatar_hash,\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.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n m.maybe_title,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)\nLEFT OUTER JOIN users\n ON m.maybe_creator_user_token = users.token\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_creator_user_token = ?\n AND m.media_class = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\n AND m.id > ?\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"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": 2,
"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": 3,
"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": 4,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_creator_username",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_creator_display_name",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_creator_gravatar_hash",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 12,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 13,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 14,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 15,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 17,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 18,
"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": 4
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "a52cc2d6e86607c430173553230b3794a4c1ada07274a975c04128e47d3b7f83"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n pci.media_token as `media_token: MediaFileToken`\nFROM prompt_context_items pci\nJOIN media_files m\n ON pci.media_token = m.token\nWHERE pci.prompt_token = ?\n AND m.media_class = 'image'\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\nORDER BY pci.id ASC\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d6dfca49327b2b31078517c5926ae51cc2490b17d8c94a97fefd3decdf8e5664"
}
@@ -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\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"
}
+1 -1
View File
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://github.com/user-attachments/assets/52a8e983-7c8f-42d2-be8b-25296ab9ed57) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
+293
View File
@@ -0,0 +1,293 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
@@ -1,6 +1,7 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -22,6 +23,28 @@ pub struct ListFolderMediaFilesQueryParams {
pub limit: Option<u32>,
}
// ── GET /v1/folders/media_files_without_folder ──
//
// NB: `ListMediaFilesWithoutFolderSuccessResponse` and its list item live in
// `storyteller_web`'s handler because the wire shape embeds
// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend
// on the request's `MediaDomain` + `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesWithoutFolderQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
/// Optional filter on the coarse media class.
///
/// Usage:
/// - `?filter_media_class=image`
/// - `?filter_media_class=video`
/// - `?filter_media_class=mesh`
/// - `?filter_media_class=splat`
pub filter_media_class: Option<MediaFileClass>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ──
#[derive(Deserialize, ToSchema)]
@@ -0,0 +1,64 @@
//! Shared types for the by-class session media file list endpoints
//! (`/v1/media_files/mesh/list`, `/v1/media_files/splat/list`).
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
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;
use crate::common::responses::user_details_light::UserDetailsLight;
/// Query string parameters for the by-class session media file lists.
/// Pagination only — the media class is fixed by the endpoint.
#[derive(Deserialize, IntoParams)]
pub struct ListSessionMediaFilesByTypeQueryParams {
pub sort_ascending: Option<bool>,
pub page_size: Option<usize>,
pub cursor: Option<String>,
pub cursor_is_reversed: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionMediaFileInfo {
/// Primary key identifier
pub token: MediaFileToken,
/// The coarse-grained class of media file.
/// Fixed per endpoint (`mesh` or `splat`).
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,
/// 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,
/// User info
pub maybe_creator_user: Option<UserDetailsLight>,
pub creator_set_visibility: Visibility,
/// The generation's prompt record, when there is one. Clients resolve it
/// to display the original prompt and model.
pub maybe_prompt_token: Option<PromptToken>,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,14 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::responses::pagination_cursors::PaginationCursors;
use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo;
pub const LIST_SESSION_MESH_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/mesh/list";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionMeshMediaFilesSuccessResponse {
pub success: bool,
pub results: Vec<SessionMediaFileInfo>,
pub pagination: PaginationCursors,
}
@@ -0,0 +1,14 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::responses::pagination_cursors::PaginationCursors;
use crate::media_file::list::by_type::list_session_common::SessionMediaFileInfo;
pub const LIST_SESSION_SPLAT_MEDIA_FILES_URL_PATH: &str = "/v1/media_files/splat/list";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionSplatMediaFilesSuccessResponse {
pub success: bool,
pub results: Vec<SessionMediaFileInfo>,
pub pagination: PaginationCursors,
}
@@ -0,0 +1,3 @@
pub mod list_session_common;
pub mod list_session_mesh_media_files;
pub mod list_session_splat_media_files;
@@ -0,0 +1 @@
pub mod by_type;
@@ -1,4 +1,5 @@
pub mod delete_media_file;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
pub mod project;
pub mod project;
@@ -41,6 +41,10 @@ pub enum RouterImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -73,4 +77,14 @@ mod tests {
fn midjourney_8_serializes_to_midjourney_8() {
assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8");
}
#[test]
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
}
#[test]
fn seedream_5p0_pro_ultra_serializes_to_seedream_5p0_pro_u() {
assert_round_trip(RouterImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
}
}
@@ -26,6 +26,8 @@ use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles:
use crate::generate::generate_image::providers::artcraft::seedream_4::build::build_artcraft_seedream_4;
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::build::build_artcraft_seedream_5p0_pro_ultra;
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
@@ -44,6 +46,7 @@ use crate::generate::generate_image::providers::fal::seedream_5_lite::build::bui
use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji;
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::build::build_kinovi_seedream_5p0_pro;
#[derive(Clone, Debug)]
pub struct GenerateImageRequestBuilder {
@@ -119,6 +122,8 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream4) => build_artcraft_seedream_4(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0ProUltra) => build_artcraft_seedream_5p0_pro_ultra(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
@@ -141,10 +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)
// 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),
_ => self.unsupported_provider_and_model(),
}
@@ -9,12 +9,14 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
/// Wrapper for all image generation draft requests.
///
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only Kinovi/Midjourney needs this — when the caller
/// be sent. Today only the Kinovi models need this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
@@ -22,6 +24,7 @@ pub enum ImageGenerationDraftRequest {
KinoviMidjourney7(KinoviMidjourney7DraftState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
KinoviMidjourney8(KinoviMidjourney8DraftState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState),
}
impl ImageGenerationDraftRequest {
@@ -30,6 +33,7 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -38,6 +42,7 @@ impl ImageGenerationDraftRequest {
Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
Self::KinoviSeedream5p0Pro(draft) => Ok(KinoviSeedream5p0ProCostState::from_draft(draft).estimate_cost()),
}
}
@@ -55,6 +60,10 @@ impl ImageGenerationDraftRequest {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney8(result))
}
Self::KinoviSeedream5p0Pro(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviSeedream5p0Pro(result))
}
}
}
}
@@ -39,6 +39,10 @@ use crate::generate::generate_image::providers::artcraft::seedream_4p5::cost::Ar
use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost::ArtcraftSeedream5LiteCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::cost::ArtcraftSeedream5p0ProUltraCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
@@ -75,6 +79,8 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost:
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Clone, Debug)]
pub enum ImageGenerationRequest {
@@ -92,6 +98,8 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -115,10 +123,11 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState),
}
impl ImageGenerationRequest {
@@ -137,6 +146,8 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0ProUltra(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -162,6 +173,7 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -207,6 +219,12 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(request) => {
Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
Ok(ArtcraftSeedream5p0ProUltraCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -243,6 +261,7 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()),
Self::KinoviSeedream5p0Pro(request) => Ok(KinoviSeedream5p0ProCostState::from_request(request).estimate_cost()),
}
}
@@ -301,6 +320,14 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0Pro(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -389,7 +416,7 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney) ──
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
@@ -405,6 +432,11 @@ impl ImageGenerationRequest {
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
}
}
}
@@ -19,3 +19,5 @@ pub(crate) mod request_common;
pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
pub mod seedream_5p0_pro_ultra;
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
pub fn build_artcraft_seedream_5p0_pro(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0Pro)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState { request }),
))
}
@@ -0,0 +1,187 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 7;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 12;
/// Cost state for Artcraft Seedream 5.0 Pro. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 7¢, 2K → 12¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (7¢) ──
#[test]
fn default_resolution_one_image_is_7c() { assert_eq!(cost_cents(None, 1), 7); }
#[test]
fn one_k_one_image_is_7c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 7); }
#[test]
fn one_k_four_images_is_28c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 28); }
// ── 2K (12¢) ──
#[test]
fn two_k_one_image_is_12c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 12); }
#[test]
fn two_k_four_images_is_48c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 48); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(12),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(7),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
pub fn build_artcraft_seedream_5p0_pro_ultra(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0ProUltra)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState { request }),
))
}
@@ -0,0 +1,208 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 18;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 30;
/// Cost state for Artcraft Seedream 5.0 Pro Ultra. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 18¢, 2K → 30¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProUltraCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProUltraRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0ProUltra,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (18¢) ──
#[test]
fn default_resolution_one_image_is_18c() { assert_eq!(cost_cents(None, 1), 18); }
#[test]
fn one_k_one_image_is_18c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 18); }
#[test]
fn one_k_four_images_is_72c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 72); }
// ── 2K (30¢) ──
#[test]
fn two_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 30); }
#[test]
fn two_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 120); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(30),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(18),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The price must exceed the base Seedream 5.0 Pro price ──
#[test]
fn price_exceeds_base_seedream_5p0_pro_price() {
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
for resolution in [None, Some(CommonResolutionEnum::OneK), Some(CommonResolutionEnum::TwoK)] {
for num_images in [1u16, 4] {
let ultra_cents = ArtcraftSeedream5p0ProUltraCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
let base_cents = ArtcraftSeedream5p0ProCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
ultra_cents > base_cents,
"ultra price {}¢ must exceed base price {}¢ ({:?} batch {})",
ultra_cents, base_cents, resolution, num_images,
);
}
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProUltraCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProUltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -1,6 +1,7 @@
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub mod seedream_5p0_pro;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -0,0 +1,408 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::{
KinoviSeedream5p0ProDraftState, KinoviSeedream5p0ProRemainingItems,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
pub fn build_kinovi_seedream_5p0_pro(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
validate_reference_image_count(image_inputs.as_ref())?;
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateSeedream5p0ProRequest {
prompt,
aspect_ratio,
resolution,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviSeedream5p0Pro(KinoviSeedream5p0ProRequestState { request }),
));
}
let draft = KinoviSeedream5p0ProDraftState {
prompt,
aspect_ratio,
resolution,
batch_count,
unhandled_request_state: Some(KinoviSeedream5p0ProRemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
/// Reject over-limit reference lists before any uploads happen — the
/// Seedance2Pro client would reject them anyway, but only after the draft
/// stage has already uploaded every image to the Kinovi CDN.
fn validate_reference_image_count(
image_inputs: Option<&ImageListRef>,
) -> Result<(), ArtcraftRouterError> {
let count = match image_inputs {
None => 0,
Some(ImageListRef::Urls(urls)) => urls.len(),
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
};
if count > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("{} reference images (max {})", count, MAX_REFERENCE_IMAGES),
}));
}
Ok(())
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProAspectRatio, ArtcraftRouterError> {
use KinoviSeedream5p0ProAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Auto),
},
}
}
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProResolution, ArtcraftRouterError> {
use KinoviSeedream5p0ProResolution as Res;
match resolution {
None | Some(RouterResolution::OneK) => Ok(Res::OneK),
Some(RouterResolution::TwoK) => Ok(Res::TwoK),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
// Snap to the nearest supported tier: sub-1k inputs (0.5K, legacy
// video resolutions) land on 1k; 3K/4K land on 2k.
_ => match unsupported {
RouterResolution::ThreeK | RouterResolution::FourK => Ok(Res::TwoK),
_ => Ok(Res::OneK),
},
},
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviSeedream5p0ProBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviSeedream5p0ProBatchCount::One),
2 => Ok(KinoviSeedream5p0ProBatchCount::Two),
3 => Ok(KinoviSeedream5p0ProBatchCount::Three),
4 => Ok(KinoviSeedream5p0ProBatchCount::Four),
5 => Ok(KinoviSeedream5p0ProBatchCount::Five),
6 => Ok(KinoviSeedream5p0ProBatchCount::Six),
7 => Ok(KinoviSeedream5p0ProBatchCount::Seven),
8 => Ok(KinoviSeedream5p0ProBatchCount::Eight),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
// Kinovi supports batches of 1–8; only counts of 9+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviSeedream5p0ProBatchCount::Eight)
}
},
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn build_request(builder: GenerateImageRequestBuilder) -> KinoviSeedream5p0ProRequestState {
match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected Request"),
}
}
mod routing_tests {
use super::*;
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_seedream_5p0_pro(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_seedream_5p0_pro(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(_)),
));
}
#[test]
fn media_file_tokens_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_seedream_5p0_pro(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn fourteen_reference_images_is_accepted() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_ok());
}
#[test]
fn fifteen_reference_images_is_rejected() {
let urls: Vec<String> = (0..MAX_REFERENCE_IMAGES + 1)
.map(|i| format!("https://example.com/ref{i}.png"))
.collect();
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_builder()
};
assert!(build_kinovi_seedream_5p0_pro(builder).is_err());
}
}
mod plan_tests {
use super::*;
#[test]
fn default_aspect_ratio_is_auto() {
let req = build_request(base_builder());
assert_eq!(req.request.aspect_ratio, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn every_supported_aspect_ratio_maps() {
let cases = [
(RouterAspectRatio::Auto, KinoviSeedream5p0ProAspectRatio::Auto),
(RouterAspectRatio::Square, KinoviSeedream5p0ProAspectRatio::Square1x1),
(RouterAspectRatio::WideFourByThree, KinoviSeedream5p0ProAspectRatio::Standard4x3),
(RouterAspectRatio::TallThreeByFour, KinoviSeedream5p0ProAspectRatio::Portrait3x4),
(RouterAspectRatio::WideSixteenByNine, KinoviSeedream5p0ProAspectRatio::Landscape16x9),
(RouterAspectRatio::TallNineBySixteen, KinoviSeedream5p0ProAspectRatio::Portrait9x16),
(RouterAspectRatio::WideThreeByTwo, KinoviSeedream5p0ProAspectRatio::Wide3x2),
(RouterAspectRatio::TallTwoByThree, KinoviSeedream5p0ProAspectRatio::Tall2x3),
(RouterAspectRatio::WideTwentyOneByNine, KinoviSeedream5p0ProAspectRatio::UltraWide21x9),
];
for (input, expected) in cases {
let planned = plan_aspect_ratio(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={:?}", input);
}
}
#[test]
fn unsupported_aspect_ratio_errors_out() {
let result = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
let planned = plan_aspect_ratio(
Some(RouterAspectRatio::TallNineByTwentyOne),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProAspectRatio::Auto);
}
#[test]
fn default_resolution_is_1k() {
let req = build_request(base_builder());
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn two_k_resolution_maps() {
let builder = GenerateImageRequestBuilder {
resolution: Some(RouterResolution::TwoK),
..base_builder()
};
let req = build_request(builder);
assert_eq!(req.request.resolution, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn four_k_resolution_errors_out() {
let result = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::ErrorOut,
);
assert!(result.is_err());
}
#[test]
fn four_k_resolution_downgrades_to_2k() {
let planned = plan_resolution(
Some(RouterResolution::FourK),
RequestMismatchMitigationStrategy::PayLessDowngrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::TwoK);
}
#[test]
fn half_k_resolution_snaps_to_1k() {
let planned = plan_resolution(
Some(RouterResolution::HalfK),
RequestMismatchMitigationStrategy::PayMoreUpgrade,
).expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProResolution::OneK);
}
#[test]
fn batch_counts_one_through_eight_are_accepted() {
let cases = [
(1, KinoviSeedream5p0ProBatchCount::One),
(2, KinoviSeedream5p0ProBatchCount::Two),
(3, KinoviSeedream5p0ProBatchCount::Three),
(4, KinoviSeedream5p0ProBatchCount::Four),
(5, KinoviSeedream5p0ProBatchCount::Five),
(6, KinoviSeedream5p0ProBatchCount::Six),
(7, KinoviSeedream5p0ProBatchCount::Seven),
(8, KinoviSeedream5p0ProBatchCount::Eight),
];
for (input, expected) in cases {
let planned = plan_batch_count(Some(input), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(planned, expected, "input={}", input);
}
}
#[test]
fn batch_count_zero_is_rejected() {
assert!(plan_batch_count(Some(0), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_errors_out() {
assert!(plan_batch_count(Some(9), RequestMismatchMitigationStrategy::ErrorOut).is_err());
}
#[test]
fn batch_count_nine_clamps_to_eight() {
let planned = plan_batch_count(Some(9), RequestMismatchMitigationStrategy::PayLessDowngrade)
.expect("plan");
assert_eq!(planned, KinoviSeedream5p0ProBatchCount::Eight);
}
}
}
@@ -0,0 +1,100 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft::KinoviSeedream5p0ProDraftState;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
/// Seedream 5.0 Pro (via Kinovi) cost state. Pricing is per image and
/// resolution-dependent (1k vs 2k); batch count multiplies.
pub struct KinoviSeedream5p0ProCostState {
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
}
impl KinoviSeedream5p0ProCostState {
pub fn from_request(request: &KinoviSeedream5p0ProRequestState) -> Self {
Self {
resolution: request.request.resolution,
batch_count: request.request.batch_count,
}
}
pub fn from_draft(draft: &KinoviSeedream5p0ProDraftState) -> Self {
Self {
resolution: draft.resolution,
batch_count: draft.batch_count,
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateSeedream5p0ProRequest {
prompt: String::new(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
ImageGenerationCostEstimate {
cost_in_credits: Some(costs.kinovi_credits),
cost_in_usd_cents: Some(costs.usd_cents_rounded_up),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits_and_six_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(14));
assert_eq!(cost.cost_in_usd_cents, Some(6)); // 1400/243 = 5.76 -> rounds UP
}
#[test]
fn one_2k_image_is_twentyfour_credits_and_ten_cents() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(96));
assert_eq!(cost.cost_in_usd_cents, Some(40)); // 9600/243 = 39.51 -> rounds UP
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let cost = KinoviSeedream5p0ProCostState {
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
}.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(112));
assert_eq!(cost.cost_in_usd_cents, Some(47)); // 11200/243 = 46.09 -> rounds UP
}
}
@@ -0,0 +1,53 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::request::KinoviSeedream5p0ProRequestState;
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProDraftState {
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
pub batch_count: KinoviSeedream5p0ProBatchCount,
pub unhandled_request_state: Option<KinoviSeedream5p0ProRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviSeedream5p0ProDraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviSeedream5p0ProRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateSeedream5p0ProRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
resolution: self.resolution,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviSeedream5p0ProRequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,141 @@
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviSeedream5p0ProRequestState {
pub request: GenerateSeedream5p0ProRequest,
}
impl KinoviSeedream5p0ProRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateSeedream5p0ProArgs {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_seedream_5p0_pro(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()),
image_inputs: None,
resolution: Some(RouterResolution::OneK),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviSeedream5p0Pro(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno_seedream5".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("The dog in @Image1 wearing a tiny crown in a royal palace".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviSeedream5p0Pro(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviSeedream5p0Pro(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -43,6 +43,9 @@ pub struct ExtractedContents {
/// Parsed from `payload.video`.
pub video: Option<VideoData>,
/// Parsed from `payload.audio` (e.g. Seed Audio 1.0 speech results).
pub audio: Option<AudioData>,
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
@@ -108,6 +111,20 @@ pub struct VideoData {
pub file_size: Option<u64>,
}
/// Data under `payload.audio` (e.g. Seed Audio 1.0):
#[derive(Debug, Deserialize)]
pub struct AudioData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub bitrate: Option<u64>,
pub channels: Option<u64>,
/// Duration in seconds.
pub duration: Option<f64>,
pub sample_rate: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too).
/// Also used for `payload.model_glb_pbr`, which has the same shape.
#[derive(Debug, Deserialize)]
@@ -0,0 +1,103 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::AudioData;
/// Extract and deserialize the `audio` key from a webhook success payload
/// (e.g. Seed Audio 1.0 speech results).
pub(crate) fn extract_audio(obj: &Map<String, Value>) -> Option<AudioData> {
let value = obj.get("audio")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn audio_from_seed_audio_1p0_test_file() {
let webhook = load_test_webhook("success/seed_audio_1p0_audio_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let audio = contents.audio.expect("audio should be Some");
assert_eq!(audio.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3"));
assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg"));
assert_eq!(audio.file_name.as_deref(), Some("speech.mp3"));
assert_eq!(audio.file_size, Some(53612));
assert_eq!(audio.duration, Some(6.63995));
assert_eq!(audio.sample_rate, Some(24000));
assert!(audio.bitrate.is_none());
assert!(audio.channels.is_none());
// No other content types in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_audio_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"audio": {
"url": "https://cdn.example.com/speech.mp3",
"content_type": "audio/mpeg",
"file_name": "speech.mp3",
"file_size": 12345,
"bitrate": 128000,
"channels": 2,
"duration": 3.5,
"sample_rate": 44100
}
}"#).unwrap();
let audio = extract_audio(&obj).expect("should extract audio");
assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.mp3"));
assert_eq!(audio.content_type.as_deref(), Some("audio/mpeg"));
assert_eq!(audio.file_name.as_deref(), Some("speech.mp3"));
assert_eq!(audio.file_size, Some(12345));
assert_eq!(audio.bitrate, Some(128000));
assert_eq!(audio.channels, Some(2));
assert_eq!(audio.duration, Some(3.5));
assert_eq!(audio.sample_rate, Some(44100));
}
#[test]
fn audio_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"audio": {"url": "https://cdn.example.com/speech.wav"}
}"#).unwrap();
let audio = extract_audio(&obj).expect("should extract audio");
assert_eq!(audio.url.as_deref(), Some("https://cdn.example.com/speech.wav"));
assert!(audio.content_type.is_none());
assert!(audio.duration.is_none());
}
#[test]
fn missing_audio_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/video.mp4"}
}"#).unwrap();
assert!(extract_audio(&obj).is_none());
}
}
@@ -1,3 +1,4 @@
pub mod extract_audio_payload;
pub mod extract_image_payload;
pub mod extract_images_payload;
pub mod extract_video_payload;
@@ -18,8 +19,8 @@ use crate::webhook_payload::hydrated::hydrated_webhook_contents::ExtractedConten
/// Try to extract known content keys from a success payload.
///
/// Checks the payload (as a JSON object) for any of: `image`, `images`,
/// `video`, `model_glb`, `model_glb_pbr`, `model_urls`, `model_mesh`,
/// `model_obj`, `result_files`, `thumbnail`, `rendered_image`,
/// `video`, `audio`, `model_glb`, `model_glb_pbr`, `model_urls`,
/// `model_mesh`, `model_obj`, `result_files`, `thumbnail`, `rendered_image`,
/// `preprocessed_image`. If at least one is found, returns
/// `Some(ExtractedContents)` with copies of the relevant values. Multiple
/// keys can be populated simultaneously. Returns `None` if the payload is
@@ -30,6 +31,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
let image = extract_image_payload::extract_image(obj);
let images = extract_images_payload::extract_images(obj);
let video = extract_video_payload::extract_video(obj);
let audio = extract_audio_payload::extract_audio(obj);
let model_glb = extract_model_glb_payload::extract_model_glb(obj);
let model_glb_pbr = extract_model_glb_pbr_payload::extract_model_glb_pbr(obj);
let model_urls = extract_model_urls_payload::extract_model_urls(obj);
@@ -40,7 +42,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
let rendered_image = extract_rendered_image_payload::extract_rendered_image(obj);
let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj);
if image.is_none() && images.is_none() && video.is_none()
if image.is_none() && images.is_none() && video.is_none() && audio.is_none()
&& model_glb.is_none() && model_glb_pbr.is_none() && model_urls.is_none()
&& model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none() && rendered_image.is_none()
@@ -53,6 +55,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
image,
images,
video,
audio,
model_glb,
model_glb_pbr,
model_urls,
@@ -0,0 +1,18 @@
{
"error": null,
"gateway_request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660",
"payload": {
"audio": {
"bitrate": null,
"channels": null,
"content_type": "audio/mpeg",
"duration": 6.63995,
"file_name": "speech.mp3",
"file_size": 53612,
"sample_rate": 24000,
"url": "https://v3b.fal.media/files/b/0aa1bc36/vOSCcxsbibkZIiXR9BIZn_speech.mp3"
}
},
"request_id": "019f4dc7-e98b-7f90-94c5-a8b131ff2660",
"status": "OK"
}
@@ -0,0 +1,421 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::de_u64_int_or_float;
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_BILLING_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetBillingPaymentsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_BILLING_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of payments actually returned until a page comes back empty.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct BillingPaymentsHistoryPage {
/// Payments on this page, newest first.
pub payments: Vec<BillingPayment>,
/// Cursor of the row after this page (`None` on the last page). The
/// dashboard paginates by limit/offset; this is informational.
pub maybe_next_cursor: Option<String>,
/// Total payment rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One payment on the Kinovi billing dashboard (a credit-package purchase).
#[derive(Debug, Clone)]
pub struct BillingPayment {
pub id: String,
/// Payment amount in US dollars (the API returns dollars: `2159`, `99.99`).
pub amount_usd: f64,
pub status: BillingPaymentStatus,
pub payment_type: BillingPaymentType,
/// Kinovi credits granted by this payment (e.g. 525,000).
pub credits_earned: u64,
/// e.g. "Credits Package"
pub product_name: String,
pub transaction_id: String,
pub payment_id: String,
pub product_id: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-13T20:40:49.405Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
pub updated_at: String,
}
/// Payment status. Only `PAID` has been observed; unrecognised values are
/// preserved in `Unknown` so future statuses (refunds, chargebacks, pending)
/// don't break deserialization.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentStatus {
Paid,
Unknown(String),
}
impl BillingPaymentStatus {
fn from_str(s: &str) -> Self {
match s {
"PAID" => Self::Paid,
other => Self::Unknown(other.to_string()),
}
}
}
/// Payment type. Only `ONE_TIME` has been observed.
#[derive(Debug, Clone, PartialEq)]
pub enum BillingPaymentType {
OneTime,
Unknown(String),
}
impl BillingPaymentType {
fn from_str(s: &str) -> Self {
match s {
"ONE_TIME" => Self::OneTime,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_billing_payments_history(
args: GetBillingPaymentsHistoryArgs<'_>,
) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/billing.getPayments", base_url);
info!("Fetching billing payments (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Billing payments response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<BillingPaymentsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let payments = json.payments
.into_iter()
.map(|p| {
let created_at_utc = DateTime::parse_from_rfc3339(&p.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
BillingPayment {
id: p.id,
amount_usd: p.amount,
status: BillingPaymentStatus::from_str(&p.payment_status),
payment_type: BillingPaymentType::from_str(&p.payment_type),
credits_earned: p.credits_earned,
product_name: p.product_name,
transaction_id: p.transaction_id,
payment_id: p.payment_id,
product_id: p.product_id,
created_at: p.created_at,
created_at_utc,
updated_at: p.updated_at,
}
})
.collect();
Ok(BillingPaymentsHistoryPage {
payments,
maybe_next_cursor: json.next_cursor,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the billing.getPayments endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
payments: Vec<RawPayment>,
#[serde(default)]
next_cursor: Option<String>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawPayment {
id: String,
amount: f64,
payment_status: String,
#[serde(default)]
transaction_id: String,
#[serde(default)]
payment_id: String,
#[serde(default)]
product_id: String,
#[serde(rename = "type")]
payment_type: String,
#[serde(deserialize_with = "de_u64_int_or_float")]
credits_earned: u64,
#[serde(default)]
product_name: String,
created_at: String,
#[serde(default)]
updated_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured billing.getPayments response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"payments":[
{"id":"31722","amount":2159,"paymentStatus":"PAID","transactionId":"","paymentId":"","createdAt":"2026-07-13T20:40:49.405Z","updatedAt":"2026-07-13T20:40:49.405Z","productId":"","type":"ONE_TIME","creditsEarned":525000,"productName":"Credits Package"},
{"id":"11491","amount":99.99,"paymentStatus":"PAID","transactionId":"202603141400154949180","paymentId":"202603141400154949180","createdAt":"2026-03-14T14:00:15.782Z","updatedAt":"2026-03-14T14:01:02.630Z","productId":"","type":"ONE_TIME","creditsEarned":22000,"productName":"Credits Package"}
],"nextCursor":"29564","total":997}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.payments.len(), 2);
assert_eq!(page.maybe_next_cursor.as_deref(), Some("29564"));
assert_eq!(page.total, 997);
let first = &page.payments[0];
assert_eq!(first.id, "31722");
assert_eq!(first.amount_usd, 2159.0);
assert_eq!(first.status, BillingPaymentStatus::Paid);
assert_eq!(first.payment_type, BillingPaymentType::OneTime);
assert_eq!(first.credits_earned, 525_000);
assert!(first.created_at_utc.is_some());
let second = &page.payments[1];
assert_eq!(second.amount_usd, 99.99);
assert_eq!(second.transaction_id, "202603141400154949180");
}
#[test]
fn last_page_has_no_next_cursor() {
let body = r#"[{"result":{"data":{"json":{"payments":[],"nextCursor":null,"total":997}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert!(page.payments.is_empty());
assert_eq!(page.maybe_next_cursor, None);
}
#[test]
fn unknown_status_and_type_are_preserved() {
let body = r#"[{"result":{"data":{"json":{"payments":[
{"id":"1","amount":5,"paymentStatus":"REFUNDED","type":"SUBSCRIPTION","creditsEarned":0,"createdAt":"2026-07-13T20:40:49.405Z"}
],"nextCursor":null,"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(page.payments[0].status, BillingPaymentStatus::Unknown("REFUNDED".to_string()));
assert_eq!(page.payments[0].payment_type, BillingPaymentType::Unknown("SUBSCRIPTION".to_string()));
}
}
mod live {
use super::*;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_payments_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("payments: {}, total: {}, next_cursor: {:?}",
page.payments.len(), page.total, page.maybe_next_cursor);
for payment in &page.payments {
println!(" {} | {} | ${:.2} | {} credits | {:?} | {:?}",
payment.id, payment.created_at, payment.amount_usd,
payment.credits_earned, payment.status, payment.payment_type);
}
assert!(!page.payments.is_empty());
Ok(())
}
/// Pages through the ENTIRE payments history with throttling and prints
/// lifetime totals. ~1,000 rows / 50 pages as of 2026-07.
/// cargo test -p seedance2pro_client live_paginate_entire_payments_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_payments_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 2_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut total_rows = 0u64;
let mut total_amount_usd = 0f64;
let mut total_credits = 0u64;
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.payments.is_empty() {
break;
}
for payment in &page.payments {
total_rows += 1;
total_amount_usd += payment.amount_usd;
total_credits += payment.credits_earned;
}
println!("page {} (offset {}): {} payments, running total ${:.2}",
pages, offset, page.payments.len(), total_amount_usd);
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.payments.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── payments history totals ──");
println!("pages: {}", pages);
println!("payments: {} (server reports total {})", total_rows, last_reported_total);
println!("lifetime spend: ${:.2}", total_amount_usd);
println!("lifetime credits: {}", total_credits);
assert!(total_rows > 0);
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -0,0 +1,415 @@
use chrono::{DateTime, Utc};
use log::info;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float};
/// Page size the kinovi.ai billing dashboard uses.
pub const DEFAULT_CREDITS_PAGE_LIMIT: u32 = 20;
// --- Args & response ---
pub struct GetCreditsHistoryArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Page size. The web dashboard uses [`DEFAULT_CREDITS_PAGE_LIMIT`].
pub limit: u32,
/// Zero-based row offset. Pagination is offset-based: advance by the
/// number of entries actually returned until a page comes back empty.
/// NB: the ledger grows while you page (offsets shift under live traffic),
/// so dedupe by entry `id` when sweeping the full history.
pub offset: u64,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
pub struct CreditsHistoryPage {
/// Ledger entries on this page, newest first.
pub entries: Vec<CreditHistoryEntry>,
/// Total ledger rows on the account, per the server.
pub total: u64,
}
// --- Public types ---
/// One row of the Kinovi credits ledger: a signed credit delta attributed to
/// a generation order (consumption) or a grant (purchase, registration bonus).
#[derive(Debug, Clone)]
pub struct CreditHistoryEntry {
pub id: u64,
pub entry_type: CreditHistoryEntryType,
/// Signed credit delta: negative for consumption, positive for grants.
pub credit_delta: i64,
/// The generation order this entry bills, when applicable (`ord_...`).
/// `None` for grants (purchases, registration bonuses).
pub maybe_order_id: Option<String>,
/// e.g. "Seedance 2.0 Video", "Credit Package - xl (25000 credits)"
pub product_name: String,
/// ISO 8601 creation timestamp (e.g. `"2026-07-14T00:31:12.820Z"`).
pub created_at: String,
/// Parsed `created_at`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
}
/// Ledger entry type. Observed values are mapped; anything new (refunds,
/// failure credits, expirations, ...) lands in `Unknown` so deserialization
/// never breaks.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CreditHistoryEntryType {
/// Credits consumed by a successful generation (negative delta).
ConsumptionSuccess,
/// Credits granted by a package purchase (positive delta).
PurchasedCredits,
/// Sign-up bonus credits (positive delta).
NewUserRegistration,
Unknown(String),
}
impl CreditHistoryEntryType {
pub fn as_str(&self) -> &str {
match self {
Self::ConsumptionSuccess => "CONSUMPTION_SUCCESS",
Self::PurchasedCredits => "PURCHASED_CREDITS",
Self::NewUserRegistration => "NEW_USER_REGISTRATION",
Self::Unknown(other) => other.as_str(),
}
}
fn from_str(s: &str) -> Self {
match s {
"CONSUMPTION_SUCCESS" => Self::ConsumptionSuccess,
"PURCHASED_CREDITS" => Self::PurchasedCredits,
"NEW_USER_REGISTRATION" => Self::NewUserRegistration,
other => Self::Unknown(other.to_string()),
}
}
}
// --- Implementation ---
pub async fn get_credits_history(
args: GetCreditsHistoryArgs<'_>,
) -> Result<CreditsHistoryPage, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/credits.getCreditHistory", base_url);
info!("Fetching credits history (limit: {}, offset: {})...", args.limit, args.offset);
let input_json = build_input_json(args.limit, args.offset);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/app/dashboard/billing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Credits history response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
parse_response_body(&response_body)
}
fn parse_response_body(response_body: &str) -> Result<CreditsHistoryPage, Seedance2ProError> {
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape {
explanation: "Empty batch response array".to_string(),
raw_body: response_body.to_string(),
})?
.result
.data
.json;
let entries = json.credits
.into_iter()
.map(|entry| {
let created_at_utc = DateTime::parse_from_rfc3339(&entry.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
CreditHistoryEntry {
id: entry.id,
entry_type: CreditHistoryEntryType::from_str(&entry.entry_type),
credit_delta: entry.credit,
maybe_order_id: entry.order_id,
product_name: entry.product_name,
created_at: entry.created_at,
created_at_utc,
}
})
.collect();
Ok(CreditsHistoryPage {
entries,
total: json.total,
})
}
/// Builds the tRPC `input` JSON for the credits.getCreditHistory endpoint.
fn build_input_json(limit: u32, offset: u64) -> String {
format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#)
}
// --- Raw response types ---
#[derive(Deserialize, Debug)]
struct BatchResponseItem {
result: RawResult,
}
#[derive(Deserialize, Debug)]
struct RawResult {
data: RawData,
}
#[derive(Deserialize, Debug)]
struct RawData {
json: RawJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawJson {
credits: Vec<RawCreditEntry>,
#[serde(deserialize_with = "de_u64_int_or_float")]
total: u64,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct RawCreditEntry {
#[serde(deserialize_with = "de_u64_int_or_float")]
id: u64,
#[serde(rename = "type")]
entry_type: String,
#[serde(deserialize_with = "de_i64_int_or_float")]
credit: i64,
#[serde(default)]
order_id: Option<String>,
created_at: String,
#[serde(default)]
product_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod offline_parsing {
use super::*;
/// Trimmed verbatim from a captured credits.getCreditHistory response
/// (external/requests/sites/kinovi.ai/2026-07-13-billing/).
const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"credits":[
{"id":2750659,"type":"CONSUMPTION_SUCCESS","credit":-454,"orderId":"ord_mff2cjk2feczmzqweizcdal2","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"},
{"id":591799,"type":"PURCHASED_CREDITS","credit":25000,"orderId":null,"createdAt":"2026-02-19T08:16:58.524Z","productName":"Credit Package - xl (25000 credits)"},
{"id":563856,"type":"NEW_USER_REGISTRATION","credit":200,"orderId":null,"createdAt":"2026-02-18T01:02:52.747Z","productName":"unknown"}
],"total":304617}}}}]"#;
#[test]
fn parses_captured_page() {
let page = parse_response_body(CAPTURED_PAGE).expect("parse");
assert_eq!(page.entries.len(), 3);
assert_eq!(page.total, 304_617);
let consumption = &page.entries[0];
assert_eq!(consumption.id, 2_750_659);
assert_eq!(consumption.entry_type, CreditHistoryEntryType::ConsumptionSuccess);
assert_eq!(consumption.credit_delta, -454);
assert_eq!(consumption.maybe_order_id.as_deref(), Some("ord_mff2cjk2feczmzqweizcdal2"));
assert!(consumption.created_at_utc.is_some());
let purchase = &page.entries[1];
assert_eq!(purchase.entry_type, CreditHistoryEntryType::PurchasedCredits);
assert_eq!(purchase.credit_delta, 25_000);
assert_eq!(purchase.maybe_order_id, None);
let registration = &page.entries[2];
assert_eq!(registration.entry_type, CreditHistoryEntryType::NewUserRegistration);
assert_eq!(registration.credit_delta, 200);
}
#[test]
fn unknown_entry_type_is_preserved() {
let body = r#"[{"result":{"data":{"json":{"credits":[
{"id":1,"type":"CONSUMPTION_REFUND","credit":454,"orderId":"ord_x","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"}
],"total":1}}}}]"#;
let page = parse_response_body(body).expect("parse");
assert_eq!(
page.entries[0].entry_type,
CreditHistoryEntryType::Unknown("CONSUMPTION_REFUND".to_string()),
);
assert_eq!(page.entries[0].credit_delta, 454);
}
#[test]
fn entry_type_round_trips_as_str() {
for raw in ["CONSUMPTION_SUCCESS", "PURCHASED_CREDITS", "NEW_USER_REGISTRATION", "SOMETHING_NEW"] {
assert_eq!(CreditHistoryEntryType::from_str(raw).as_str(), raw);
}
}
}
mod live {
use super::*;
use std::collections::HashMap;
use std::collections::HashSet;
#[tokio::test]
#[ignore] // manual test — requires real cookies
async fn live_fetch_first_credits_page() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: DEFAULT_CREDITS_PAGE_LIMIT,
offset: 0,
host_override: None,
}).await?;
println!("entries: {}, total: {}", page.entries.len(), page.total);
for entry in &page.entries {
println!(" {} | {} | {:?} | {} | {:?}",
entry.id, entry.created_at, entry.entry_type, entry.credit_delta,
entry.maybe_order_id);
}
assert!(!page.entries.is_empty());
Ok(())
}
/// Pages through the ENTIRE credits ledger with throttling and prints
/// per-type tallies (this is where any refund-like entry types will
/// surface). ~300k rows as of 2026-07 — expect a long run; the ledger
/// also grows while paging, so entries are deduped by id.
/// cargo test -p seedance2pro_client live_paginate_entire_credits_history -- --ignored --nocapture
#[tokio::test]
#[ignore] // manual test — requires real cookies; fetches every page
async fn live_paginate_entire_credits_history() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let session = test_session()?;
const PAGE_LIMIT: u32 = 100;
const PAGE_DELAY_MS: u64 = 250;
const MAX_PAGES: usize = 50_000; // runaway guard
let mut offset: u64 = 0;
let mut pages = 0usize;
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut type_counts: HashMap<CreditHistoryEntryType, (u64, i64)> = HashMap::new();
let mut last_reported_total = 0u64;
for _ in 0..MAX_PAGES {
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: PAGE_LIMIT,
offset,
host_override: None,
}).await?;
pages += 1;
last_reported_total = page.total;
if page.entries.is_empty() {
break;
}
for entry in &page.entries {
if !seen_ids.insert(entry.id) {
continue; // offset shifted under live traffic; already counted
}
let tally = type_counts.entry(entry.entry_type.clone()).or_insert((0, 0));
tally.0 += 1;
tally.1 += entry.credit_delta;
}
if pages % 25 == 0 {
println!("page {} (offset {}): {} unique entries so far (server total {})",
pages, offset, seen_ids.len(), page.total);
}
// Advance by what was actually returned so a server-side limit cap
// can never skip rows.
offset += page.entries.len() as u64;
tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await;
}
println!("── credits ledger totals ──");
println!("pages: {}", pages);
println!("unique entries: {} (server reports total {})", seen_ids.len(), last_reported_total);
let mut types: Vec<_> = type_counts.iter().collect();
types.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (entry_type, (count, delta_sum)) in types {
println!(" {:<28} n={:<8} credit_sum={}", entry_type.as_str(), count, delta_sum);
}
assert!(!seen_ids.is_empty());
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -0,0 +1,2 @@
pub mod get_billing_payments_history;
pub mod get_credits_history;
@@ -0,0 +1,620 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "seedream-5-pro-image-generation";
const MODEL: &str = "seedream-5.0-pro";
/// Kinovi allows at most this many reference images per request.
pub const MAX_REFERENCE_IMAGES: usize = 14;
/// Credits per generated image at 1k resolution.
const CREDITS_PER_IMAGE_1K: u64 = 14;
/// Credits per generated image at 2k resolution.
const CREDITS_PER_IMAGE_2K: u64 = 24;
// ── Args ──
pub struct GenerateSeedream5p0ProArgs<'a> {
pub request: GenerateSeedream5p0ProRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Seedream 5.0 Pro: text-to-image (optionally with reference images).
#[derive(Clone, Debug)]
pub struct GenerateSeedream5p0ProRequest {
/// Reference images are cited in the prompt as @Image1, @Image2, etc.
pub prompt: String,
pub aspect_ratio: KinoviSeedream5p0ProAspectRatio,
pub resolution: KinoviSeedream5p0ProResolution,
/// Number of images to generate in a single request.
pub batch_count: KinoviSeedream5p0ProBatchCount,
/// Optional reference image URLs, up to [`MAX_REFERENCE_IMAGES`].
/// Referenced in prompts as @Image1, @Image2, etc.
pub reference_image_urls: Option<Vec<String>>,
}
// ── Enums ──
/// Aspect ratios supported by Seedream 5.0 Pro.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProAspectRatio {
/// Let the model pick (useful with reference images).
Auto,
Square1x1,
Standard4x3,
Portrait3x4,
Landscape16x9,
Portrait9x16,
Wide3x2,
Tall2x3,
UltraWide21x9,
}
/// Output resolution. Determines the per-image credit price.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProResolution {
/// 1k (14 credits per image)
OneK,
/// 2k (24 credits per image)
TwoK,
}
/// Number of images to generate in a single request.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviSeedream5p0ProBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedream 5.0 Pro credit pricing (per image; batch count multiplies):
//
// | Resolution | Credits/image |
// |------------|---------------|
// | 1k | 14 |
// | 2k | 24 |
//
// Reference images do not affect cost.
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedream5p0ProRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
let credits_per_image = match self.resolution {
KinoviSeedream5p0ProResolution::OneK => CREDITS_PER_IMAGE_1K,
KinoviSeedream5p0ProResolution::TwoK => CREDITS_PER_IMAGE_2K,
};
let total_credits = credits_per_image * u64::from(self.batch_count.as_u8());
KinoviGenerationCost::from_kinovi_credits(total_credits)
}
}
impl KinoviSeedream5p0ProBatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
Self::Five => 5,
Self::Six => 6,
Self::Seven => 7,
Self::Eight => 8,
}
}
}
impl KinoviSeedream5p0ProAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Square1x1 => "1:1",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
Self::UltraWide21x9 => "21:9",
}
}
}
impl KinoviSeedream5p0ProResolution {
fn as_api_str(&self) -> &'static str {
match self {
Self::OneK => "1k",
Self::TwoK => "2k",
}
}
}
// ── Response ──
pub struct GenerateSeedream5p0ProResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedream_5p0_pro(
args: GenerateSeedream5p0ProArgs<'_>,
) -> Result<GenerateSeedream5p0ProResponse, Seedance2ProError> {
let api_params = build_api_params(&args.request)?;
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedream5p0ProResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `seedream-5-pro-image-generation`. Field order
/// matches the captured browser traffic.
#[derive(Serialize, Debug)]
struct Seedream5p0ProApiParams {
prompt: String,
#[serde(rename = "aspectRatio")]
aspect_ratio: &'static str,
resolution: &'static str,
model: &'static str,
/// Omitted when generating a single image.
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
batch_count: Option<u8>,
/// Omitted when no reference images are attached.
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
uploaded_urls: Option<Vec<String>>,
}
fn build_api_params(
request: &GenerateSeedream5p0ProRequest,
) -> Result<Seedream5p0ProApiParams, Seedance2ProClientError> {
let uploaded_urls = match &request.reference_image_urls {
Some(urls) if urls.len() > MAX_REFERENCE_IMAGES => {
return Err(Seedance2ProClientError::InvalidRequestField {
field: "reference_image_urls",
raw_value: format!("{} urls", urls.len()),
reason: format!("At most {} reference images are allowed", MAX_REFERENCE_IMAGES),
});
}
Some(urls) if !urls.is_empty() => Some(urls.clone()),
_ => None,
};
let batch_count_value = request.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
Ok(Seedream5p0ProApiParams {
prompt: request.prompt.clone(),
aspect_ratio: request.aspect_ratio.as_api_str(),
resolution: request.resolution.as_api_str(),
model: MODEL,
batch_count,
uploaded_urls,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
prompt: "An anime girl riding a dinosaur".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 1_seedream_5.txt (auto aspect ratio, 2k, single image,
/// no references).
#[test]
fn auto_2k_single_image() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl riding a dinosaur","aspectRatio":"auto","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 2_seedream_5_21x9.txt (21:9, 2k, single image).
#[test]
fn ultra_wide_21x9_2k() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "a pirate sailing a pirate ship".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"a pirate sailing a pirate ship","aspectRatio":"21:9","resolution":"2k","model":"seedream-5.0-pro"}"#,
);
}
/// Mirrors capture 3_seedream_5_9x16_image_refs.txt (9:16, 1k, single
/// image, two reference images).
#[test]
fn portrait_9x16_1k_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The man in @Image1 is staring out the door in @Image2 ","aspectRatio":"9:16","resolution":"1k","model":"seedream-5.0-pro","uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg","https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png"]}"#,
);
}
/// Mirrors capture 4_seedream_5_misc.txt (2:3, 2k, batch of 4, two
/// reference images).
#[test]
fn tall_2x3_2k_batch_four_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Tall2x3,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"An anime girl is standing in the door @Image1 Outside the door, there's the galaxy and windmill @Image2 ","aspectRatio":"2:3","resolution":"2k","model":"seedream-5.0-pro","batchCount":4,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png","https://static.seedance2-pro.com/materials/20260713/1783904588294-5c713908.jpg"]}"#,
);
}
/// Mirrors capture 5_seedream_5_misc2.txt (1:1, 1k, batch of 8, two
/// reference images).
#[test]
fn square_1x1_1k_batch_eight_with_image_references() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
prompt: "The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Square1x1,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Eight,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png".to_string(),
]),
}).unwrap();
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"prompt":"The mirror from @Image2 is in front of the windmill in @Image1 . The sky and galaxy are lit up at night. ","aspectRatio":"1:1","resolution":"1k","model":"seedream-5.0-pro","batchCount":8,"uploadedUrls":["https://static.seedance2-pro.com/materials/20260713/1783904715554-1a7ce415.jpg","https://static.seedance2-pro.com/materials/20260713/1783904719453-1d10e81f.png"]}"#,
);
}
#[test]
fn every_aspect_ratio_wire_value() {
let cases = [
(KinoviSeedream5p0ProAspectRatio::Auto, "auto"),
(KinoviSeedream5p0ProAspectRatio::Square1x1, "1:1"),
(KinoviSeedream5p0ProAspectRatio::Standard4x3, "4:3"),
(KinoviSeedream5p0ProAspectRatio::Portrait3x4, "3:4"),
(KinoviSeedream5p0ProAspectRatio::Landscape16x9, "16:9"),
(KinoviSeedream5p0ProAspectRatio::Portrait9x16, "9:16"),
(KinoviSeedream5p0ProAspectRatio::Wide3x2, "3:2"),
(KinoviSeedream5p0ProAspectRatio::Tall2x3, "2:3"),
(KinoviSeedream5p0ProAspectRatio::UltraWide21x9, "21:9"),
];
for (variant, expected) in cases {
assert_eq!(variant.as_api_str(), expected, "wire value for {variant:?}");
}
}
#[test]
fn every_resolution_wire_value() {
assert_eq!(KinoviSeedream5p0ProResolution::OneK.as_api_str(), "1k");
assert_eq!(KinoviSeedream5p0ProResolution::TwoK.as_api_str(), "2k");
}
#[test]
fn every_batch_count_value() {
let cases = [
(KinoviSeedream5p0ProBatchCount::One, 1),
(KinoviSeedream5p0ProBatchCount::Two, 2),
(KinoviSeedream5p0ProBatchCount::Three, 3),
(KinoviSeedream5p0ProBatchCount::Four, 4),
(KinoviSeedream5p0ProBatchCount::Five, 5),
(KinoviSeedream5p0ProBatchCount::Six, 6),
(KinoviSeedream5p0ProBatchCount::Seven, 7),
(KinoviSeedream5p0ProBatchCount::Eight, 8),
];
for (variant, expected) in cases {
assert_eq!(variant.as_u8(), expected, "value for {variant:?}");
}
}
#[test]
fn batch_count_one_omits_field() {
let params = build_api_params(&base_request()).unwrap();
assert_eq!(params.batch_count, None);
}
#[test]
fn empty_reference_list_omits_uploaded_urls() {
let params = build_api_params(&GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![]),
..base_request()
}).unwrap();
let json = serde_json::to_string(&params).unwrap();
assert!(!json.contains("uploadedUrls"), "expected no uploadedUrls in {json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "seedream-5-pro-image-generation");
}
}
mod reference_image_limit_tests {
use super::*;
#[test]
fn fourteen_references_is_allowed() {
let params = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES)).unwrap();
assert_eq!(params.uploaded_urls.map(|urls| urls.len()), Some(MAX_REFERENCE_IMAGES));
}
#[test]
fn fifteen_references_is_rejected() {
let result = build_api_params(&request_with_n_references(MAX_REFERENCE_IMAGES + 1));
match result {
Err(Seedance2ProClientError::InvalidRequestField { field, .. }) => {
assert_eq!(field, "reference_image_urls");
}
other => panic!("expected InvalidRequestField, got {other:?}"),
}
}
fn request_with_n_references(count: usize) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
reference_image_urls: Some(
(0..count).map(|i| format!("https://example.com/ref{i}.jpg")).collect(),
),
..base_request()
}
}
}
// ── Pricing ──
//
// 1k = 14 credits/image, 2k = 24 credits/image; batch count multiplies.
// Verified against captured traffic: 2k x 4 images = 96 credits
// (4_seedream_5_misc.txt), 1k x 8 images = 112 credits
// (5_seedream_5_misc2.txt).
mod pricing_tests {
use super::*;
#[test]
fn one_1k_image_is_fourteen_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 14);
}
#[test]
fn one_2k_image_is_twentyfour_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.kinovi_credits, 24);
}
/// Mirrors capture 4_seedream_5_misc.txt: 2k, batch of 4 → 96 credits.
#[test]
fn four_2k_images_is_ninetysix_credits() {
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
assert_eq!(costs.kinovi_credits, 96);
}
/// Mirrors capture 5_seedream_5_misc2.txt: 1k, batch of 8 → 112 credits.
#[test]
fn eight_1k_images_is_one_hundred_twelve_credits() {
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.kinovi_credits, 112);
}
#[test]
fn usd_cents_one_1k_image() {
// 14 credits; 1400/243 = 5.7613… → up 6¢, down 5¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 6);
assert_eq!(costs.usd_cents_rounded_down, 5);
assert!((costs.usd_cents_fractional - (1400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_one_2k_image() {
// 24 credits; 2400/243 = 9.8765… → up 10¢, down 9¢.
let costs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 10);
assert_eq!(costs.usd_cents_rounded_down, 9);
assert!((costs.usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_eight_1k_images() {
// 112 credits; 11200/243 = 46.0905… → up 47¢, down 46¢.
let costs = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::Eight).calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 47);
assert_eq!(costs.usd_cents_rounded_down, 46);
assert!((costs.usd_cents_fractional - (11200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn reference_images_do_not_affect_cost() {
let without_refs = request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four).calculate_costs();
let with_refs = GenerateSeedream5p0ProRequest {
reference_image_urls: Some(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
]),
..request(KinoviSeedream5p0ProResolution::TwoK, KinoviSeedream5p0ProBatchCount::Four)
}.calculate_costs();
assert_eq!(without_refs, with_refs);
}
#[test]
fn aspect_ratio_does_not_affect_cost() {
let auto = request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One).calculate_costs();
let ultra_wide = GenerateSeedream5p0ProRequest {
aspect_ratio: KinoviSeedream5p0ProAspectRatio::UltraWide21x9,
..request(KinoviSeedream5p0ProResolution::OneK, KinoviSeedream5p0ProBatchCount::One)
}.calculate_costs();
assert_eq!(auto, ultra_wide);
}
fn request(
resolution: KinoviSeedream5p0ProResolution,
batch_count: KinoviSeedream5p0ProBatchCount,
) -> GenerateSeedream5p0ProRequest {
GenerateSeedream5p0ProRequest {
resolution,
batch_count,
..base_request()
}
}
}
// ── Live usage tests ──
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 24 credits.
async fn test_text_to_image_2k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A corgi wearing a wizard hat, studio lighting".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Auto,
resolution: KinoviSeedream5p0ProResolution::TwoK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro 2k — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 14 credits.
async fn test_text_to_image_1k_with_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "The man in @Image1 is staring out the door in @Image2".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Portrait9x16,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260713/1783904468032-0a803390.jpg".to_string(),
"https://static.seedance2-pro.com/materials/20260713/1783904481112-4a3fa0d2.png".to_string(),
]),
},
}).await?;
println!("seedream 5.0 pro 1k refs — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs 56 credits.
async fn test_batch_four_1k() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedream_5p0_pro(GenerateSeedream5p0ProArgs {
session: &session,
host_override: None,
request: GenerateSeedream5p0ProRequest {
prompt: "A lighthouse on a cliff at golden hour".to_string(),
aspect_ratio: KinoviSeedream5p0ProAspectRatio::Landscape16x9,
resolution: KinoviSeedream5p0ProResolution::OneK,
batch_count: KinoviSeedream5p0ProBatchCount::Four,
reference_image_urls: None,
},
}).await?;
println!("seedream 5.0 pro batch-4 — task_id={}, order_id={}", result.task_id, result.order_id);
assert_eq!(result.task_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(result.order_ids.as_ref().map(|ids| ids.len()), Some(4));
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -1,3 +1,4 @@
pub mod generate_midjourney_v7;
pub mod generate_midjourney_v7_niji;
pub mod generate_midjourney_v8;
pub mod generate_seedream_5p0_pro;
@@ -2,6 +2,7 @@
pub (crate) mod test_utils;
// User API
pub mod billing;
pub mod cost;
pub mod creds;
pub mod error;
@@ -19,7 +19,9 @@ pub fn setup_test_logging(level: LevelFilter) {
println!("Log level: {:?}", env::var(RUST_LOG));
Builder::new()
// try_init: several #[ignore] live tests may run in one process, and only
// the first can install the logger.
let _ = Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
@@ -31,7 +33,7 @@ pub fn setup_test_logging(level: LevelFilter) {
})
.filter(None, level)
.filter_level(level)
.init();
.try_init();
trace!("Test trace log");
debug!("Test debug log");
@@ -28,6 +28,20 @@ fn coerce_to_u32(number: &Number) -> u32 {
coerce_to_u64(number).min(u32::MAX as u64) as u32
}
/// Coerce a parsed JSON number to `i64`. Exact integers pass through; floats
/// are rounded; non-finite values become `0`.
fn coerce_to_i64(number: &Number) -> i64 {
if let Some(signed) = number.as_i64() {
return signed;
}
if let Some(float) = number.as_f64() {
if float.is_finite() {
return float.round() as i64;
}
}
0
}
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
/// arrive as an integer or a float.
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
@@ -38,6 +52,16 @@ where
Ok(coerce_to_u64(&number))
}
/// `#[serde(deserialize_with = "...")]` for a required `i64` field that may
/// arrive as an integer or a float (e.g. signed credit deltas).
pub fn de_i64_int_or_float<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_i64(&number))
}
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
/// `null` / missing as `None`).
pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
@@ -90,6 +114,12 @@ mod tests {
value: Option<u32>,
}
#[derive(Deserialize)]
struct I64Holder {
#[serde(deserialize_with = "de_i64_int_or_float")]
value: i64,
}
fn parse_u32(json: &str) -> u32 {
serde_json::from_str::<U32Holder>(json).unwrap().value
}
@@ -98,6 +128,10 @@ mod tests {
serde_json::from_str::<U64Holder>(json).unwrap().value
}
fn parse_i64(json: &str) -> i64 {
serde_json::from_str::<I64Holder>(json).unwrap().value
}
#[test]
fn u32_accepts_integer() {
assert_eq!(parse_u32(r#"{"value": 568}"#), 568);
@@ -126,6 +160,13 @@ mod tests {
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
}
#[test]
fn i64_accepts_negative_integers_and_floats() {
assert_eq!(parse_i64(r#"{"value": -454}"#), -454);
assert_eq!(parse_i64(r#"{"value": -454.6}"#), -455);
assert_eq!(parse_i64(r#"{"value": 25000}"#), 25000);
}
#[test]
fn optional_handles_null_missing_and_values() {
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
@@ -7,6 +7,10 @@ use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &[
"account_info",
"audit_credits",
"audit_orders",
"audit_payments",
"failed_job_histogram",
"find_job",
"generate_video",
@@ -15,6 +19,21 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Print the account's current credit balances as one CSV line
AccountInfo(subcommands::account_info::AccountInfoArgs),
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV
/// back to a start date, for refund-entry auditing
AuditCredits(subcommands::audit_credits::AuditCreditsArgs),
/// Dump Kinovi's per-order billing records (totalCredits) to CSV for a
/// date window, to audit whether failed orders are charged
AuditOrders(subcommands::audit_orders::AuditOrdersArgs),
/// Dump the account's billing payments history (credit-package
/// purchases) to CSV, for reconciling refills against invoices
AuditPayments(subcommands::audit_payments::AuditPaymentsArgs),
/// Find a job by its order ID across all pages
FindJob(subcommands::find_job::FindJobArgs),
@@ -32,6 +51,10 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
Seedance2proCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
Seedance2proCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
Seedance2proCommand::AuditPayments(args) => subcommands::audit_payments::run(&state, args).await,
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
@@ -0,0 +1,46 @@
use anyhow::anyhow;
use clap::Args;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::get_user_auth_details::get_user_auth_details::{
get_user_auth_details, GetUserAuthDetailsArgs,
};
use super::super::state::Seedance2ProState;
/// Print the account's current credit balances (auth.user endpoint) as a
/// single CSV line: account,email,credits,available_credits.
#[derive(Args)]
pub struct AccountInfoArgs {
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label for the output line. Defaults to the cookies env var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AccountInfoArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let details = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
host_override: None,
}).await
.map_err(|err| anyhow!("Error fetching auth details: {:?}", err))?;
println!("{},{},{},{}",
account_label, details.email, details.credits, details.available_credits);
Ok(())
}
@@ -0,0 +1,167 @@
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro_client::billing::get_credits_history::{
get_credits_history, CreditHistoryEntry, GetCreditsHistoryArgs,
};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use super::super::state::Seedance2ProState;
/// Dump the account's credits ledger (credits.getCreditHistory) to CSV,
/// newest-first, back to a start date. The ledger is offset-paginated and
/// grows while paging (new rows push older rows to higher offsets), which
/// causes re-reads rather than gaps - consumers should dedupe by entry id.
#[derive(Args)]
pub struct AuditCreditsArgs {
/// Output CSV path. Appends if the file exists.
#[arg(long)]
pub out: String,
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
#[arg(long)]
pub start_date: Option<String>,
/// Page size to request (the server may cap it; paging advances by rows
/// actually returned, so a cap cannot skip rows).
#[arg(long, default_value_t = 100)]
pub limit: u32,
/// Maximum pages to fetch this run.
#[arg(long, default_value_t = 10_000)]
pub max_pages: usize,
/// Resume from a row offset (e.g. after an interrupted run).
#[arg(long, default_value_t = 0)]
pub offset: u64,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditCreditsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,entry_id,created_at,entry_type,credit_delta,order_id,product_name")?;
}
let mut offset = args.offset;
let mut rows = 0u64;
let mut type_tallies: HashMap<String, (u64, i64)> = HashMap::new();
let mut last_reported_total = 0u64;
for page_number in 1..=args.max_pages {
let page = get_credits_history(GetCreditsHistoryArgs {
session: &session,
limit: args.limit,
offset,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error fetching credits page {} (resume with --offset {}): {:?}",
page_number, offset, err))?;
last_reported_total = page.total;
if page.entries.is_empty() {
info!("Page {} returned no entries; reached the end of the ledger.", page_number);
break;
}
let newest_day = page.entries.first().map(|e| day_of(e)).unwrap_or_default();
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
for entry in &page.entries {
write_entry_row(&mut out, &account_label, entry)?;
rows += 1;
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
tally.0 += 1;
tally.1 += entry.credit_delta;
}
out.flush()?;
if page_number % 25 == 0 || page_number == 1 {
info!(
"Page {} (offset {}): {} entries ({} .. {}), written {} (server total {})",
page_number, offset, page.entries.len(), newest_day, oldest_day, rows, page.total,
);
}
// Newest-first: once a whole page predates the window, everything
// after it does too.
if args.start_date.as_deref().is_some_and(|start| oldest_day.as_str() < start) {
info!("Reached entries older than {}; done.", args.start_date.as_deref().unwrap());
break;
}
offset += page.entries.len() as u64;
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
println!("── audit_credits summary [{}] ──", account_label);
println!("entries written: {} (server reports ledger total {}; duplicates possible", rows, last_reported_total);
println!(" under live growth - dedupe by entry_id downstream)");
let mut tallies: Vec<_> = type_tallies.iter().collect();
tallies.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (entry_type, (count, delta_sum)) in tallies {
println!(" {:<28} n={:<9} credit_sum={}", entry_type, count, delta_sum);
}
Ok(())
}
/// UTC day prefix ("YYYY-MM-DD"); ISO strings compare lexicographically.
fn day_of(entry: &CreditHistoryEntry) -> String {
entry.created_at.get(..10).unwrap_or("").to_string()
}
fn write_entry_row(out: &mut impl Write, account: &str, entry: &CreditHistoryEntry) -> anyhow::Result<()> {
writeln!(
out,
"{},{},{},{},{},{},{}",
account,
entry.id,
entry.created_at,
entry.entry_type.as_str(),
entry.credit_delta,
entry.maybe_order_id.as_deref().unwrap_or(""),
csv_quote(&entry.product_name),
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -0,0 +1,243 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::{info, warn};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::poll_orders::poll_orders::{
poll_orders, OrderStatus, PollOrdersArgs, TaskStatus,
};
use super::super::state::Seedance2ProState;
/// Dump Kinovi's own per-order billing records (`totalCredits`) to CSV for a
/// date window, by paging the getOrders API newest-to-oldest. Used to audit
/// whether failed orders carry charges.
#[derive(Args)]
pub struct AuditOrdersArgs {
/// Output CSV path. Appends if the file exists (resume-friendly).
#[arg(long)]
pub out: String,
/// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD).
/// Without it, paging stops at --max_pages.
#[arg(long)]
pub start_date: Option<String>,
/// Skip (do not write) orders on days after this UTC date (YYYY-MM-DD,
/// inclusive). Pages still have to be fetched to reach older orders.
#[arg(long)]
pub end_date: Option<String>,
/// Maximum pages to fetch this run (30 orders per page).
#[arg(long, default_value_t = 200)]
pub max_pages: usize,
/// Resume from a cursor logged by a previous run.
#[arg(long)]
pub cursor: Option<u64>,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name (or "SEEDANCE2PRO_COOKIES").
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditOrdersArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,order_id,created_at,task_status,media_type,total_credits,result_count,fail_reason")?;
}
let mut cursor = args.cursor;
let mut stats = AuditStats::default();
for page in 1..=args.max_pages {
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error polling page {} (resume with --cursor {:?}): {:?}", page, cursor, err))?;
if result.orders.is_empty() {
info!("Page {} returned no orders; stopping.", page);
break;
}
let newest = order_day(result.orders.first().unwrap());
let oldest = order_day(result.orders.last().unwrap());
for order in &result.orders {
let day = order_day(order);
if args.end_date.as_deref().is_some_and(|end| day > end) {
stats.skipped_after_window += 1;
continue;
}
if args.start_date.as_deref().is_some_and(|start| day < start) {
stats.skipped_before_window += 1;
continue;
}
write_order_row(&mut out, &account_label, order)?;
stats.record(order);
}
out.flush()?;
info!(
"Page {}: {} orders ({} .. {}), written so far: {}, next cursor: {:?}",
page, result.orders.len(), newest, oldest, stats.written, result.next_cursor,
);
// The API pages newest-to-oldest: once the whole page predates the
// window, everything after it will too.
if args.start_date.as_deref().is_some_and(|start| oldest < start) {
info!("Reached orders older than {}; done.", args.start_date.as_deref().unwrap());
cursor = result.next_cursor;
break;
}
cursor = result.next_cursor;
if cursor.is_none() {
info!("No further pages; reached the end of the account's history.");
break;
}
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
stats.print_summary(&account_label, cursor);
Ok(())
}
#[derive(Default)]
struct AuditStats {
written: u64,
skipped_after_window: u64,
skipped_before_window: u64,
completed: u64,
completed_credits: u64,
completed_missing_credits: u64,
failed: u64,
failed_credits: u64,
failed_with_nonzero_credits: u64,
in_flight: u64,
in_flight_credits: u64,
}
impl AuditStats {
fn record(&mut self, order: &OrderStatus) {
self.written += 1;
let credits = u64::from(order.total_credits.unwrap_or(0));
match order.task_status {
TaskStatus::Completed => {
self.completed += 1;
self.completed_credits += credits;
if order.total_credits.is_none() {
self.completed_missing_credits += 1;
}
}
TaskStatus::Failed => {
self.failed += 1;
self.failed_credits += credits;
if credits > 0 {
self.failed_with_nonzero_credits += 1;
}
}
_ => {
self.in_flight += 1;
self.in_flight_credits += credits;
}
}
}
fn print_summary(&self, account: &str, resume_cursor: Option<u64>) {
// Final results are intentionally println: they ARE the tool's output.
println!("── audit_orders summary [{}] ──", account);
println!("orders written: {}", self.written);
println!(" completed: {} ({} credits; {} missing totalCredits)",
self.completed, self.completed_credits, self.completed_missing_credits);
println!(" failed: {} ({} credits; {} orders with credits > 0)",
self.failed, self.failed_credits, self.failed_with_nonzero_credits);
println!(" pending/processing: {} ({} credits)", self.in_flight, self.in_flight_credits);
println!("skipped (newer than window): {}", self.skipped_after_window);
println!("skipped (older than window): {}", self.skipped_before_window);
match resume_cursor {
Some(c) => println!("resume cursor: {}", c),
None => println!("resume cursor: (none - history exhausted or window done)"),
}
if self.failed_with_nonzero_credits > 0 {
warn!(
"{} FAILED orders carry non-zero totalCredits ({} credits total) — \
Kinovi's own records say failures were charged.",
self.failed_with_nonzero_credits, self.failed_credits,
);
}
}
}
/// UTC day prefix ("YYYY-MM-DD") of the order's ISO 8601 created_at.
/// ISO strings compare lexicographically, so no date parsing is needed.
fn order_day(order: &OrderStatus) -> &str {
order.created_at.get(..10).unwrap_or("")
}
fn write_order_row(out: &mut impl Write, account: &str, order: &OrderStatus) -> anyhow::Result<()> {
let media_type = order.media_type.as_ref()
.map(|m| format!("{:?}", m))
.unwrap_or_default();
let total_credits = order.total_credits
.map(|c| c.to_string())
.unwrap_or_default();
let fail_reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("");
writeln!(
out,
"{},{},{},{:?},{},{},{},{}",
account,
order.order_id,
order.created_at,
order.task_status,
media_type,
total_credits,
order.results.len(),
csv_quote(fail_reason),
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -0,0 +1,131 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::Duration;
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro_client::billing::get_billing_payments_history::{
get_billing_payments_history, BillingPayment, GetBillingPaymentsHistoryArgs,
DEFAULT_BILLING_PAGE_LIMIT,
};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use super::super::state::Seedance2ProState;
/// Dump the account's full billing payments history (credit-package
/// purchases) to CSV, for reconciling refill charges against invoices.
#[derive(Args)]
pub struct AuditPaymentsArgs {
/// Output CSV path. Appends if the file exists.
#[arg(long)]
pub out: String,
/// Maximum pages to fetch (20 payments per page).
#[arg(long, default_value_t = 500)]
pub max_pages: usize,
/// Delay between page fetches, in milliseconds.
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &Seedance2ProState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
.map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?;
if file_is_new {
writeln!(out, "account,payment_id,created_at,amount_usd,credits_earned,status,payment_type,product_name,transaction_id")?;
}
let mut offset: u64 = 0;
let mut rows = 0u64;
let mut total_amount = 0f64;
let mut total_credits = 0u64;
let mut last_reported_total = 0u64;
for page_number in 1..=args.max_pages {
let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs {
session: &session,
limit: DEFAULT_BILLING_PAGE_LIMIT,
offset,
host_override: None,
}).await
.map_err(|err| anyhow!(
"Error fetching payments page {} (offset {}): {:?}", page_number, offset, err))?;
last_reported_total = page.total;
if page.payments.is_empty() {
break;
}
for payment in &page.payments {
write_payment_row(&mut out, &account_label, payment)?;
rows += 1;
total_amount += payment.amount_usd;
total_credits += payment.credits_earned;
}
out.flush()?;
info!(
"Page {} (offset {}): {} payments; running ${:.2} / {} credits (server total {})",
page_number, offset, page.payments.len(), total_amount, total_credits, page.total,
);
offset += page.payments.len() as u64;
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
println!("── audit_payments summary [{}] ──", account_label);
println!("payments written: {} (server reports total {})", rows, last_reported_total);
println!("lifetime charged: ${:.2}", total_amount);
println!("lifetime credits: {}", total_credits);
Ok(())
}
fn write_payment_row(out: &mut impl Write, account: &str, payment: &BillingPayment) -> anyhow::Result<()> {
writeln!(
out,
"{},{},{},{},{},{:?},{:?},{},{}",
account,
payment.id,
payment.created_at,
payment.amount_usd,
payment.credits_earned,
payment.status,
payment.payment_type,
csv_quote(&payment.product_name),
payment.transaction_id,
)?;
Ok(())
}
/// Minimal CSV quoting for the one free-text field.
fn csv_quote(field: &str) -> String {
if field.is_empty() {
return String::new();
}
format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " "))
}
@@ -1,3 +1,7 @@
pub mod account_info;
pub mod audit_credits;
pub mod audit_orders;
pub mod audit_payments;
pub mod failed_job_histogram;
pub mod find_job;
pub mod generate_video;
@@ -54,6 +54,8 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4),
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
RouterImageModel::Seedream5p0ProUltra => Some(CommonModelType::Seedream5p0ProUltra),
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
@@ -0,0 +1,216 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
pub struct ListMediaFilesWithoutFolderArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
/// Optional filter on the coarse media class (image / video / mesh / ...).
pub maybe_filter_media_class: Option<MediaFileClass>,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Paginated list of the user's media files that sit in no folder at all.
/// Memberships whose folder was soft-deleted (but whose link row wasn't
/// cleaned up) count as unfoldered. Excludes soft-deleted and intermediate
/// system files. Newest first (`media_files.id` descending; the id doubles
/// as the cursor).
///
/// The NOT EXISTS probe is a point lookup on
/// `folder_media_files.index_media_file_token` plus a primary-key lookup
/// on `folders` for the soft-delete check — cheap per candidate row.
pub async fn list_media_files_without_folder<'e, 'c: 'e, E>(
args: ListMediaFilesWithoutFolderArgs<'e, 'c, E>,
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// No-cursor requests use a sentinel above every possible id, which
// keeps this to a single static query per filter variant.
// (`media_files.id` is a signed BIGINT, so the sentinel is i64::MAX.)
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
let limit = args.limit as i64;
match args.maybe_filter_media_class {
Some(media_class) => {
select_page_with_class_filter(
args.owner_user_token,
media_class,
cursor_id,
limit,
args.mysql_executor,
).await
}
None => {
select_page_all_classes(
args.owner_user_token,
cursor_id,
limit,
args.mysql_executor,
).await
}
}
}
// NB: The two queries below are intentionally duplicated so that sqlx can
// check them at compile time (no QueryBuilder). Keep the SELECT lists and
// predicates in sync; they differ only in the `media_class = ?` filter.
async fn select_page_all_classes<'e, 'c: 'e, E>(
owner_user_token: &'e UserToken,
cursor_id: u64,
limit: i64,
mysql_executor: E,
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query_as!(
MediaFileListRow,
r#"
SELECT
mf.id as `media_file_id: u64`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_class as `media_class: MediaFileClass`,
mf.media_type as `media_type: MediaFileType`,
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
mf.is_user_upload as `is_user_upload: bool`,
mf.maybe_title,
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
mf.maybe_origin_filename,
mf.maybe_duration_millis,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.created_at,
mf.updated_at
FROM media_files mf
LEFT JOIN media_files cover
ON cover.token = mf.maybe_cover_image_media_file_token
WHERE mf.maybe_creator_user_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND NOT mf.is_intermediate_system_file
AND NOT EXISTS (
SELECT 1
FROM folder_media_files fmf
JOIN folders f
ON f.token = fmf.folder_token
AND f.maybe_deleted_at IS NULL
WHERE fmf.media_file_token = mf.token
)
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(mysql_executor)
.await
}
async fn select_page_with_class_filter<'e, 'c: 'e, E>(
owner_user_token: &'e UserToken,
media_class: MediaFileClass,
cursor_id: u64,
limit: i64,
mysql_executor: E,
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query_as!(
MediaFileListRow,
r#"
SELECT
mf.id as `media_file_id: u64`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_class as `media_class: MediaFileClass`,
mf.media_type as `media_type: MediaFileType`,
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
mf.is_user_upload as `is_user_upload: bool`,
mf.maybe_title,
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
mf.maybe_origin_filename,
mf.maybe_duration_millis,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.created_at,
mf.updated_at
FROM media_files mf
LEFT JOIN media_files cover
ON cover.token = mf.maybe_cover_image_media_file_token
WHERE mf.maybe_creator_user_token = ?
AND mf.media_class = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND NOT mf.is_intermediate_system_file
AND NOT EXISTS (
SELECT 1
FROM folder_media_files fmf
JOIN folders f
ON f.token = fmf.folder_token
AND f.maybe_deleted_at IS NULL
WHERE fmf.media_file_token = mf.token
)
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
owner_user_token.as_str(),
media_class.to_str(),
cursor_id,
limit,
)
.fetch_all(mysql_executor)
.await
}
@@ -2,4 +2,5 @@ pub mod bulk_delete_folder_media_files;
pub mod bulk_insert_folder_media_files;
pub mod filter_existing_media_file_tokens;
pub mod list_folder_media_files;
pub mod list_media_files_without_folder;
pub mod recompute_folder_last_media_files;
@@ -82,7 +82,9 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
let is_image_model = match args.maybe_model_type {
Some(CommonModelType::Midjourney7) |
Some(CommonModelType::Midjourney7Niji) |
Some(CommonModelType::Midjourney8) => true,
Some(CommonModelType::Midjourney8) |
Some(CommonModelType::Seedream5p0Pro) |
Some(CommonModelType::Seedream5p0ProUltra) => true,
_ => false,
};
@@ -110,7 +112,12 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
InferenceJobExternalThirdParty::Seedance2ProAlt,
InferenceJobProductCategory::Seedance2ProVideoAlt,
),
(KinoviVersion::BytePlusUltra, _) => (
(KinoviVersion::BytePlusUltra, true) => (
InferenceJobType::Seedance2ProBytePlusUltraQueue,
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
InferenceJobProductCategory::Seedance2ProImageBytePlusUltra,
),
(KinoviVersion::BytePlusUltra, false) => (
InferenceJobType::Seedance2ProBytePlusUltraQueue,
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra,
@@ -1,5 +1,4 @@
use anyhow::anyhow;
use sqlx;
use sqlx::MySqlPool;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
@@ -22,6 +21,7 @@ use tokens::tokens::users::UserToken;
use crate::payloads::media_file_extra_info::media_file_extra_info::MediaFileExtraInfo;
use crate::queries::generic_synthetic_ids::transactional_increment_generic_synthetic_id::transactional_increment_generic_synthetic_id;
use crate::queries::media_files::create::generic_insert::insert_media_file_generic_executor::{insert_media_file_generic_executor, InsertMediaFileGenericExecutorArgs};
pub struct InsertArgs<'a> {
pub pool: &'a MySqlPool,
@@ -54,7 +54,7 @@ pub struct InsertArgs<'a> {
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub checksum_sha2: &'a str,
// Media info for certain product areas
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
@@ -104,12 +104,13 @@ pub struct InsertArgs<'a> {
pub maybe_mod_user_token: Option<&'a UserToken>,
}
/// Insert a media file record, allocating the creator's synthetic IDs in the
/// same transaction. Wraps [`insert_media_file_generic_executor`], which runs
/// the actual INSERT.
pub async fn insert_media_file_generic(
args: InsertArgs<'_>
) -> AnyhowResult<(MediaFileToken, u64)>
{
let result_token = MediaFileToken::generate();
let extra_file_modification_info = args
.maybe_extra_media_info.map(|extra| extra.to_json_string())
.transpose()?;
@@ -117,24 +118,8 @@ pub async fn insert_media_file_generic(
let mut maybe_creator_file_synthetic_id : Option<u64> = None;
let mut maybe_creator_category_synthetic_id : Option<u64> = None;
let mut maybe_generation_provider_str = None;
let mut is_intermediate_system_file = args.is_intermediate_system_file;
let mut is_user_upload = args.is_user_upload;
let mut origin_category = args.origin_category;
if let Some(generation_provider) = args.maybe_generation_provider {
// Overrides if we're using a generation provider
maybe_generation_provider_str = Some(generation_provider.to_str());
is_intermediate_system_file = false;
is_user_upload = false;
if generation_provider != GenerationProvider::Artcraft {
origin_category = MediaFileOriginCategory::ThirdPartyInference;
}
}
let mut transaction = args.pool.begin().await?;
if let Some(user_token) = args.maybe_creator_user_token.as_deref() {
let next_media_file_id = transactional_increment_generic_synthetic_id(
user_token,
@@ -152,131 +137,53 @@ pub async fn insert_media_file_generic(
maybe_creator_category_synthetic_id = Some(category_id);
}
let query_result = sqlx::query!(
r#"
INSERT INTO media_files
SET
token = ?,
media_class = ?,
media_type = ?,
is_user_upload = ?,
is_intermediate_system_file = ?,
origin_category = ?,
origin_product_category = ?,
maybe_origin_model_type = ?,
maybe_origin_model_token = ?,
maybe_origin_filename = ?,
maybe_batch_token = ?,
maybe_mime_type = ?,
file_size_bytes = ?,
maybe_duration_millis = ?,
maybe_audio_encoding = ?,
maybe_video_encoding = ?,
maybe_frame_width = ?,
maybe_frame_height = ?,
maybe_prompt_token = ?,
checksum_sha2 = ?,
maybe_engine_category = ?,
maybe_title = ?,
maybe_text_transcript = ?,
maybe_scene_source_media_file_token = ?,
public_bucket_directory_hash = ?,
maybe_public_bucket_prefix = ?,
maybe_public_bucket_extension = ?,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
maybe_creator_file_synthetic_id = ?,
maybe_creator_category_synthetic_id = ?,
extra_file_modification_info = ?,
maybe_generation_provider = ?,
platform_type = ?,
maybe_cover_image_media_file_token = ?,
maybe_mod_user_token = ?,
is_generated_on_prem = ?,
generated_by_worker = ?,
generated_by_cluster = ?
"#,
result_token,
args.media_class.to_str(),
args.media_type.to_str(),
is_user_upload,
is_intermediate_system_file,
origin_category.to_str(),
args.origin_product_category.to_str(),
args.maybe_origin_model_type.map(|e| e.to_str()),
args.maybe_origin_model_token.map(|t| t.to_string()),
args.maybe_origin_filename,
args.maybe_batch_token.map(|t| t.as_str()),
args.maybe_mime_type,
args.file_size_bytes,
args.maybe_duration_millis,
args.maybe_audio_encoding,
args.maybe_video_encoding,
args.maybe_frame_width,
args.maybe_frame_height,
args.maybe_prompt_token.map(|t| t.as_str()),
args.checksum_sha2,
args.maybe_engine_category.map(|e| e.to_str()),
args.maybe_title,
args.maybe_text_transcript,
args.maybe_scene_source_media_file_token.map(|t| t.as_str()),
args.public_bucket_directory_hash,
args.maybe_public_bucket_prefix,
args.maybe_public_bucket_extension,
args.maybe_creator_user_token.map(|t| t.as_str()),
args.maybe_creator_anonymous_visitor_token.map(|t| t.as_str()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
let query_result = insert_media_file_generic_executor(InsertMediaFileGenericExecutorArgs {
maybe_creator_user_token: args.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: args.maybe_creator_anonymous_visitor_token,
creator_ip_address: args.creator_ip_address,
creator_set_visibility: args.creator_set_visibility,
media_class: args.media_class,
media_type: args.media_type,
is_user_upload: args.is_user_upload,
is_intermediate_system_file: args.is_intermediate_system_file,
origin_category: args.origin_category,
origin_product_category: args.origin_product_category,
maybe_origin_model_type: args.maybe_origin_model_type,
maybe_origin_model_token: args.maybe_origin_model_token,
maybe_origin_filename: args.maybe_origin_filename,
maybe_mime_type: args.maybe_mime_type,
file_size_bytes: args.file_size_bytes,
maybe_duration_millis: args.maybe_duration_millis,
maybe_audio_encoding: args.maybe_audio_encoding,
maybe_video_encoding: args.maybe_video_encoding,
maybe_frame_width: args.maybe_frame_width,
maybe_frame_height: args.maybe_frame_height,
checksum_sha2: args.checksum_sha2,
maybe_engine_category: args.maybe_engine_category,
maybe_title: args.maybe_title,
maybe_text_transcript: args.maybe_text_transcript,
maybe_scene_source_media_file_token: args.maybe_scene_source_media_file_token,
maybe_prompt_token: args.maybe_prompt_token,
maybe_batch_token: args.maybe_batch_token,
public_bucket_directory_hash: args.public_bucket_directory_hash,
maybe_public_bucket_prefix: args.maybe_public_bucket_prefix,
maybe_public_bucket_extension: args.maybe_public_bucket_extension,
maybe_creator_file_synthetic_id,
maybe_creator_category_synthetic_id,
maybe_extra_file_modification_info: extra_file_modification_info,
is_generated_on_prem: args.is_generated_on_prem,
generated_by_worker: args.generated_by_worker,
generated_by_cluster: args.generated_by_cluster,
maybe_generation_provider: args.maybe_generation_provider,
maybe_platform_type: args.maybe_platform_type,
maybe_cover_image_media_file_token: args.maybe_cover_image_media_file_token,
maybe_mod_user_token: args.maybe_mod_user_token,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
extra_file_modification_info,
maybe_generation_provider_str,
args.maybe_platform_type.map(|p| p.to_str()),
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
args.maybe_mod_user_token,
args.is_generated_on_prem,
args.generated_by_worker,
args.generated_by_cluster
).execute(&mut *transaction).await;
let record_id = match query_result {
Ok(res) => res.last_insert_id(),
let (result_token, record_id) = match query_result {
Ok(result) => result,
Err(err) => {
// TODO: handle better
//transaction.rollback().await?;
@@ -0,0 +1,267 @@
use std::marker::PhantomData;
use sqlx;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::platform_type::PlatformType;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
/// Arguments for [`insert_media_file_generic_executor`].
///
/// This is the executor-generic core of `insert_media_file_generic`: it runs
/// the single INSERT statement and nothing else. Synthetic-ID increments and
/// extra-info serialization stay with the caller (see
/// `insert_media_file_generic`, which wraps this in a transaction).
pub struct InsertMediaFileGenericExecutorArgs<'a, 'c, E>
where
E: 'a + Executor<'c, Database = MySql>,
{
// Creator info
pub maybe_creator_user_token: Option<&'a UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<&'a AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'a str,
pub creator_set_visibility: Visibility,
// Important database indices
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
// Product and other origination information
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<&'a ModelWeightToken>,
pub maybe_origin_filename: Option<String>,
// Media info
pub maybe_mime_type: Option<&'a str>,
pub file_size_bytes: u64,
pub maybe_duration_millis: Option<u64>,
pub maybe_audio_encoding: Option<&'a str>,
pub maybe_video_encoding: Option<&'a str>,
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub checksum_sha2: &'a str,
// Media info for certain product areas
pub maybe_engine_category: Option<MediaFileEngineCategory>, // TODO: Deprecate
// User text information
pub maybe_title: Option<&'a str>,
pub maybe_text_transcript: Option<&'a str>,
// If generated from a scene, this is the scene media file token.
pub maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
// If additional prompt details are stored, this is the prompt token.
pub maybe_prompt_token: Option<&'a PromptToken>,
// If batch generated, this is the batch token.
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
// Storage details
pub public_bucket_directory_hash: &'a str,
pub maybe_public_bucket_prefix: Option<&'a str>,
pub maybe_public_bucket_extension: Option<&'a str>,
// Counters, pre-computed by the caller (see
// `transactional_increment_generic_synthetic_id`).
pub maybe_creator_file_synthetic_id: Option<u64>,
pub maybe_creator_category_synthetic_id: Option<u64>,
/// Extra polymorphic data stored in `extra_file_modification_info`,
/// pre-serialized to JSON by the caller.
pub maybe_extra_file_modification_info: Option<String>,
// Worker generation info
pub is_generated_on_prem: bool,
pub generated_by_worker: Option<&'a str>,
pub generated_by_cluster: Option<&'a str>,
/// If provided, the third-party provider that generated this file.
pub maybe_generation_provider: Option<GenerationProvider>,
/// The platform the creating request came from, inferred from its User-Agent.
/// For inference results, this is copied from the originating job.
pub maybe_platform_type: Option<PlatformType>,
// Cover image (e.g. thumbnail for 3D splats)
pub maybe_cover_image_media_file_token: Option<&'a MediaFileToken>,
// Moderation details (deprecated)
pub maybe_mod_user_token: Option<&'a UserToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a media file record using any executor (pool, connection, or open
/// transaction). This is the single-statement core shared by
/// `insert_media_file_generic` and the specialized inserts.
pub async fn insert_media_file_generic_executor<'a, 'c: 'a, E>(
args: InsertMediaFileGenericExecutorArgs<'a, 'c, E>,
) -> Result<(MediaFileToken, u64), sqlx::Error>
where
E: 'a + Executor<'c, Database = MySql>,
{
let result_token = MediaFileToken::generate();
let mut maybe_generation_provider_str = None;
let is_intermediate_system_file = args.is_intermediate_system_file;
let mut is_user_upload = args.is_user_upload;
let mut origin_category = args.origin_category;
if let Some(generation_provider) = args.maybe_generation_provider {
// Overrides if we're using a generation provider.
// NB: `is_intermediate_system_file` is deliberately NOT reset here:
// generated cover images / thumbnails set both a generation provider
// and the intermediate flag, and clobbering the flag made them appear
// in users' asset collections. The flag defaults to false at call
// sites, so honoring the explicit setting is always correct.
maybe_generation_provider_str = Some(generation_provider.to_str());
is_user_upload = false;
if generation_provider != GenerationProvider::Artcraft {
origin_category = MediaFileOriginCategory::ThirdPartyInference;
}
}
let query_result = sqlx::query!(
r#"
INSERT INTO media_files
SET
token = ?,
media_class = ?,
media_type = ?,
is_user_upload = ?,
is_intermediate_system_file = ?,
origin_category = ?,
origin_product_category = ?,
maybe_origin_model_type = ?,
maybe_origin_model_token = ?,
maybe_origin_filename = ?,
maybe_batch_token = ?,
maybe_mime_type = ?,
file_size_bytes = ?,
maybe_duration_millis = ?,
maybe_audio_encoding = ?,
maybe_video_encoding = ?,
maybe_frame_width = ?,
maybe_frame_height = ?,
maybe_prompt_token = ?,
checksum_sha2 = ?,
maybe_engine_category = ?,
maybe_title = ?,
maybe_text_transcript = ?,
maybe_scene_source_media_file_token = ?,
public_bucket_directory_hash = ?,
maybe_public_bucket_prefix = ?,
maybe_public_bucket_extension = ?,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
maybe_creator_file_synthetic_id = ?,
maybe_creator_category_synthetic_id = ?,
extra_file_modification_info = ?,
maybe_generation_provider = ?,
platform_type = ?,
maybe_cover_image_media_file_token = ?,
maybe_mod_user_token = ?,
is_generated_on_prem = ?,
generated_by_worker = ?,
generated_by_cluster = ?
"#,
result_token,
args.media_class.to_str(),
args.media_type.to_str(),
is_user_upload,
is_intermediate_system_file,
origin_category.to_str(),
args.origin_product_category.to_str(),
args.maybe_origin_model_type.map(|e| e.to_str()),
args.maybe_origin_model_token.map(|t| t.to_string()),
args.maybe_origin_filename,
args.maybe_batch_token.map(|t| t.as_str()),
args.maybe_mime_type,
args.file_size_bytes,
args.maybe_duration_millis,
args.maybe_audio_encoding,
args.maybe_video_encoding,
args.maybe_frame_width,
args.maybe_frame_height,
args.maybe_prompt_token.map(|t| t.as_str()),
args.checksum_sha2,
args.maybe_engine_category.map(|e| e.to_str()),
args.maybe_title,
args.maybe_text_transcript,
args.maybe_scene_source_media_file_token.map(|t| t.as_str()),
args.public_bucket_directory_hash,
args.maybe_public_bucket_prefix,
args.maybe_public_bucket_extension,
args.maybe_creator_user_token.map(|t| t.as_str()),
args.maybe_creator_anonymous_visitor_token.map(|t| t.as_str()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_creator_file_synthetic_id,
args.maybe_creator_category_synthetic_id,
args.maybe_extra_file_modification_info,
maybe_generation_provider_str,
args.maybe_platform_type.map(|p| p.to_str()),
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
args.maybe_mod_user_token,
args.is_generated_on_prem,
args.generated_by_worker,
args.generated_by_cluster
).execute(args.mysql_executor).await?;
Ok((result_token, query_result.last_insert_id()))
}
@@ -1,2 +1,3 @@
pub mod insert_media_file_generic;
pub mod insert_media_file_generic_executor;
pub mod insert_media_file_generic_from_job;
@@ -302,19 +302,22 @@ impl MediaFileInsertBuilder {
"Origin category is required".to_string()))?;
let mut maybe_generation_provider_str = None;
let mut is_intermediate_system_file = self.is_intermediate_system_file;
let is_intermediate_system_file = self.is_intermediate_system_file;
let mut is_user_upload = self.is_user_upload;
if let Some(generation_provider) = self.maybe_generation_provider {
// Overrides if we're using a generation provider
// Overrides if we're using a generation provider.
// NB: `is_intermediate_system_file` is deliberately NOT reset here:
// generated cover images / thumbnails set both a generation provider
// and the intermediate flag, and clobbering the flag made them appear
// in users' asset collections. The flag defaults to false, so honoring
// the explicit setter is always correct.
maybe_generation_provider_str = Some(generation_provider.to_str());
is_intermediate_system_file = false;
is_user_upload = false;
if generation_provider != GenerationProvider::Artcraft {
origin_category = MediaFileOriginCategory::ThirdPartyInference;
}
}
let result = insert_media_file_generic(InsertArgs {
pool: mysql_pool,
@@ -0,0 +1,122 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::platform_type::PlatformType;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::create::generic_insert::insert_media_file_generic_executor::{insert_media_file_generic_executor, InsertMediaFileGenericExecutorArgs};
pub struct InsertCoverMediaFileArgs<'a, 'c, E>
where
E: 'a + Executor<'c, Database = MySql>,
{
// Creator info (copied from the generation job).
pub maybe_creator_user_token: Option<&'a UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<&'a AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'a str,
/// The image format of the cover (png, jpg, webp, ...).
pub media_file_type: MediaFileType,
pub mime_type: &'a str,
pub file_size_bytes: u64,
pub checksum_sha2: &'a str,
pub public_bucket_path: &'a MediaFileBucketPath,
/// Product category of the generation the cover belongs to (e.g.
/// `WorldGeneration` for gaussian splat covers).
pub maybe_origin_product_category: Option<MediaFileOriginProductCategory>,
pub maybe_prompt_token: Option<&'a PromptToken>,
pub maybe_platform_type: Option<PlatformType>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a cover image media file record (e.g. a mesh/splat thumbnail
/// downloaded from a generation provider).
///
/// Cover images are always `media_class = image` and — critically — always
/// `is_intermediate_system_file = TRUE`, so they never appear in the user's
/// asset collection. They are low-quality preview renders, not assets the
/// user paid to generate.
///
/// Cover images deliberately do not consume the creator's synthetic file IDs:
/// those number the user's visible assets, and covers are hidden.
pub async fn insert_cover_media_file<'a, 'c: 'a, E>(
args: InsertCoverMediaFileArgs<'a, 'c, E>,
) -> Result<MediaFileToken, sqlx::Error>
where
E: 'a + Executor<'c, Database = MySql>,
{
let (media_token, _record_id) = insert_media_file_generic_executor(InsertMediaFileGenericExecutorArgs {
// User details
maybe_creator_user_token: args.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: args.maybe_creator_anonymous_visitor_token,
creator_ip_address: args.creator_ip_address,
maybe_platform_type: args.maybe_platform_type,
// Storage
public_bucket_directory_hash: args.public_bucket_path.get_object_hash(),
maybe_public_bucket_prefix: args.public_bucket_path.get_optional_prefix(),
maybe_public_bucket_extension: args.public_bucket_path.get_optional_extension(),
// File details
checksum_sha2: args.checksum_sha2,
file_size_bytes: args.file_size_bytes,
maybe_mime_type: Some(args.mime_type),
media_class: MediaFileClass::Image,
// Additional foreign keys and misc
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_prompt_token: args.maybe_prompt_token,
media_type: args.media_file_type,
origin_product_category: args.maybe_origin_product_category // TODO: Not useful here.
.unwrap_or(MediaFileOriginProductCategory::Unknown),
// Essential - this is a hidden file
is_intermediate_system_file: true,
creator_set_visibility: Visibility::Hidden,
// Other Constants
generated_by_cluster: None,
generated_by_worker: None,
is_generated_on_prem: false,
is_user_upload: false,
maybe_audio_encoding: None,
maybe_batch_token: None,
maybe_cover_image_media_file_token: None,
maybe_creator_category_synthetic_id: None,
maybe_creator_file_synthetic_id: None,
maybe_duration_millis: None,
maybe_engine_category: None,
maybe_extra_file_modification_info: None,
maybe_frame_height: None,
maybe_frame_width: None,
maybe_mod_user_token: None,
maybe_origin_filename: None,
maybe_origin_model_token: None,
maybe_origin_model_type: None,
maybe_scene_source_media_file_token: None,
maybe_text_transcript: None,
maybe_title: None,
maybe_video_encoding: None,
origin_category: MediaFileOriginCategory::Inference, // TODO: Not useful here.
// Connection
mysql_executor: args.mysql_executor,
phantom: Default::default(),
}).await?;
Ok(media_token)
}
@@ -1,3 +1,4 @@
pub mod insert_cover_media_file;
pub mod insert_media_file_from_cli_tool;
pub mod insert_media_file_from_comfy_ui;
pub mod insert_media_file_from_f5_tts;
@@ -9,4 +10,4 @@ pub mod insert_media_file_from_seed_vc;
pub mod insert_media_file_from_studio_gen2;
pub mod insert_media_file_from_tacotron2;
pub mod insert_media_file_from_voice_conversion;
pub mod insert_media_file_from_zero_shot_tts;
pub mod insert_media_file_from_zero_shot_tts;
@@ -0,0 +1,249 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct SessionMediaFileListPage {
pub records: Vec<SessionMediaFileListItem>,
/// ID of the first record (for building the "previous" cursor).
pub first_id: Option<u64>,
/// ID of the last record (for building the "next" cursor).
pub last_id: Option<u64>,
}
pub struct SessionMediaFileListItem {
pub id: u64,
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_username: Option<String>,
pub maybe_creator_display_name: Option<String>,
pub maybe_creator_gravatar_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub creator_set_visibility: Visibility,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct ListSessionMediaFilesByClassArgs<'a, 'c, E>
where
E: 'a + Executor<'c, Database = MySql>,
{
pub user_token: &'a UserToken,
/// The media class to list (e.g. `mesh` or `splat`).
pub media_class: MediaFileClass,
pub limit: usize,
/// Keyset cursor: a `media_files.id` from a previous page.
pub maybe_cursor_id: Option<u64>,
pub cursor_is_reversed: bool,
pub sort_ascending: bool,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// List the session user's media files of a single class (e.g. `mesh` or
/// `splat`), newest first by default, with keyset pagination on `id`.
pub async fn list_session_media_files_by_class<'a, 'c: 'a, E>(
args: ListSessionMediaFilesByClassArgs<'a, 'c, E>,
) -> Result<SessionMediaFileListPage, sqlx::Error>
where
E: 'a + Executor<'c, Database = MySql>,
{
// A reversed cursor walks the opposite direction of the sort, so the effective
// scan direction is the XOR of the two flags (same scheme as `list_media_files`).
let ascending = args.sort_ascending != args.cursor_is_reversed;
let limit = args.limit as u64;
let records = if ascending {
// NB: IDs start at 1, so a zero sentinel means "no cursor".
let cursor_id = args.maybe_cursor_id.unwrap_or(0);
select_page_ascending(
args.user_token,
args.media_class,
cursor_id,
limit,
args.mysql_executor,
).await?
} else {
let cursor_id = args.maybe_cursor_id.unwrap_or(u64::MAX);
select_page_descending(
args.user_token,
args.media_class,
cursor_id,
limit,
args.mysql_executor,
).await?
};
let first_id = records.first().map(|record| record.id);
let last_id = records.last().map(|record| record.id);
Ok(SessionMediaFileListPage {
records,
first_id,
last_id,
})
}
// NB: The two queries below are intentionally duplicated so that sqlx can check
// them at compile time (no QueryBuilder). Keep the SELECT lists and predicates
// in sync; they differ only in the cursor comparison direction and sort order.
async fn select_page_ascending<'a, 'c: 'a, E>(
user_token: &'a UserToken,
media_class: MediaFileClass,
cursor_id: u64,
limit: u64,
mysql_executor: E,
) -> Result<Vec<SessionMediaFileListItem>, sqlx::Error>
where
E: 'a + Executor<'c, Database = MySql>,
{
sqlx::query_as!(
SessionMediaFileListItem,
r#"
SELECT
m.id as `id: u64`,
m.token as `token: tokens::tokens::media_files::MediaFileToken`,
m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,
m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
users.display_name as maybe_creator_display_name,
users.email_gravatar_hash as maybe_creator_gravatar_hash,
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,
m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
m.maybe_title,
m.created_at,
m.updated_at
FROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)
LEFT OUTER JOIN users
ON m.maybe_creator_user_token = users.token
LEFT OUTER JOIN media_files as media_file_cover_image
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
WHERE
m.maybe_creator_user_token = ?
AND m.media_class = ?
AND m.user_deleted_at IS NULL
AND m.mod_deleted_at IS NULL
AND NOT m.is_intermediate_system_file
AND m.id > ?
ORDER BY m.id ASC
LIMIT ?
"#,
user_token,
media_class.to_str(),
cursor_id,
limit,
)
.fetch_all(mysql_executor)
.await
}
async fn select_page_descending<'a, 'c: 'a, E>(
user_token: &'a UserToken,
media_class: MediaFileClass,
cursor_id: u64,
limit: u64,
mysql_executor: E,
) -> Result<Vec<SessionMediaFileListItem>, sqlx::Error>
where
E: 'a + Executor<'c, Database = MySql>,
{
sqlx::query_as!(
SessionMediaFileListItem,
r#"
SELECT
m.id as `id: u64`,
m.token as `token: tokens::tokens::media_files::MediaFileToken`,
m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,
m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension,
users.token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
users.username as maybe_creator_username,
users.display_name as maybe_creator_display_name,
users.email_gravatar_hash as maybe_creator_gravatar_hash,
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,
m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
m.maybe_title,
m.created_at,
m.updated_at
FROM media_files AS m FORCE INDEX (fk_maybe_creator_user_token)
LEFT OUTER JOIN users
ON m.maybe_creator_user_token = users.token
LEFT OUTER JOIN media_files as media_file_cover_image
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
WHERE
m.maybe_creator_user_token = ?
AND m.media_class = ?
AND m.user_deleted_at IS NULL
AND m.mod_deleted_at IS NULL
AND NOT m.is_intermediate_system_file
AND m.id < ?
ORDER BY m.id DESC
LIMIT ?
"#,
user_token,
media_class.to_str(),
cursor_id,
limit,
)
.fetch_all(mysql_executor)
.await
}
@@ -4,5 +4,6 @@ pub mod list_media_files;
pub mod list_media_files_by_batch_token;
pub mod list_media_files_for_elastic_search_backfill_using_cursor;
pub mod list_media_files_for_user;
pub mod list_session_media_files_by_class;
pub mod list_session_project_media_files;
pub mod media_file_list_row;
@@ -0,0 +1,47 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
pub struct GetFirstImagerefForPromptArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub prompt_token: &'e PromptToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Find the first (oldest-attached) non-deleted image reference attached to a
/// prompt. Used as a cover-image fallback for mesh/splat generations whose
/// webhook payload carries no thumbnail: the image the user generated the
/// model from stands in as the cover.
pub async fn get_first_imageref_for_prompt<'e, 'c: 'e, E>(
args: GetFirstImagerefForPromptArgs<'e, 'c, E>,
) -> Result<Option<MediaFileToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
pci.media_token as `media_token: MediaFileToken`
FROM prompt_context_items pci
JOIN media_files m
ON pci.media_token = m.token
WHERE pci.prompt_token = ?
AND m.media_class = 'image'
AND m.user_deleted_at IS NULL
AND m.mod_deleted_at IS NULL
ORDER BY pci.id ASC
LIMIT 1
"#,
args.prompt_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| r.media_token))
}
@@ -1,3 +1,4 @@
pub mod batch_list_prompt_context_items;
pub mod get_first_imageref_for_prompt;
pub mod insert_batch_prompt_context_items;
pub mod list_prompt_context_items;
pub mod list_prompt_context_items;
@@ -72,6 +72,10 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "seedance2pro_video_bpu")]
Seedance2ProVideoBytePlusUltra,
/// Seedance 2 Pro / Kinovi (BytePlus Ultra account): image generation.
#[serde(rename = "seedance2pro_image_bpu")]
Seedance2ProImageBytePlusUltra,
// =============== WORLD LABS ===============
/// World Labs: Gaussian Splat Generation
@@ -183,6 +187,7 @@ impl InferenceJobProductCategory {
Self::Seedance2ProAudio => "seedance2pro_audio",
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
Self::Seedance2ProImageBytePlusUltra => "seedance2pro_image_bpu",
Self::WorldlabsSplat => "worldlabs_splat",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
@@ -226,6 +231,7 @@ impl InferenceJobProductCategory {
"seedance2pro_audio" => Ok(Self::Seedance2ProAudio),
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
"seedance2pro_image_bpu" => Ok(Self::Seedance2ProImageBytePlusUltra),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
@@ -272,6 +278,7 @@ impl InferenceJobProductCategory {
Self::Seedance2ProAudio,
Self::Seedance2ProVideoAlt,
Self::Seedance2ProVideoBytePlusUltra,
Self::Seedance2ProImageBytePlusUltra,
Self::WorldlabsSplat,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
@@ -324,6 +331,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
assert_serialization(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra, "seedance2pro_image_bpu");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
@@ -366,6 +374,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
assert_eq!(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra.to_str(), "seedance2pro_image_bpu");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
@@ -407,6 +416,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProImageBytePlusUltra);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
@@ -434,7 +444,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 38;
const EXPECTED_COUNT : usize = 39;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -127,6 +127,10 @@ pub enum InferenceModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_v6")]
@@ -334,6 +338,8 @@ impl InferenceModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -462,6 +468,8 @@ impl InferenceModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -594,6 +602,8 @@ impl InferenceModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Seedream5p0ProUltra,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -709,6 +719,8 @@ impl InferenceModelType {
CommonModelType::Seedream4 => Self::Seedream4,
CommonModelType::Seedream4p5 => Self::Seedream4p5,
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
CommonModelType::Seedream5p0ProUltra => Self::Seedream5p0ProUltra,
CommonModelType::Midjourney => Self::Midjourney,
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
@@ -847,6 +859,7 @@ mod tests {
assert_serialization(InferenceModelType::Seedream4, "seedream_4");
assert_serialization(InferenceModelType::Seedream4p5, "seedream_4p5");
assert_serialization(InferenceModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(InferenceModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(InferenceModelType::Midjourney, "midjourney");
assert_serialization(InferenceModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(InferenceModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -972,6 +985,7 @@ mod tests {
assert_eq!(InferenceModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(InferenceModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(InferenceModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(InferenceModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(InferenceModelType::Midjourney.to_str(), "midjourney");
assert_eq!(InferenceModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(InferenceModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -1099,6 +1113,7 @@ mod tests {
assert_eq!(InferenceModelType::from_str("seedream_4").unwrap(), InferenceModelType::Seedream4);
assert_eq!(InferenceModelType::from_str("seedream_4p5").unwrap(), InferenceModelType::Seedream4p5);
assert_eq!(InferenceModelType::from_str("seedream_5_lite").unwrap(), InferenceModelType::Seedream5Lite);
assert_eq!(InferenceModelType::from_str("seedream_5p0_pro").unwrap(), InferenceModelType::Seedream5p0Pro);
assert_eq!(InferenceModelType::from_str("midjourney").unwrap(), InferenceModelType::Midjourney);
assert_eq!(InferenceModelType::from_str("midjourney_v6").unwrap(), InferenceModelType::MidjourneyV6);
assert_eq!(InferenceModelType::from_str("midjourney_v6p1").unwrap(), InferenceModelType::MidjourneyV6p1);
@@ -47,6 +47,10 @@ pub enum CommonImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -75,6 +79,8 @@ impl CommonImageModel {
Self::Seedream4 => CommonModelType::Seedream4,
Self::Seedream4p5 => CommonModelType::Seedream4p5,
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
Self::Seedream5p0ProUltra => CommonModelType::Seedream5p0ProUltra,
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
}
@@ -111,6 +117,8 @@ mod tests {
assert_serialization(CommonImageModel::Seedream4, "seedream_4");
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
}
@@ -136,6 +144,8 @@ mod tests {
(CommonImageModel::Seedream4, CommonModelType::Seedream4),
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
(CommonImageModel::Seedream5p0ProUltra, CommonModelType::Seedream5p0ProUltra),
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
];
@@ -164,7 +174,7 @@ mod tests {
#[test]
fn variant_count() {
use strum::IntoEnumIterator;
assert_eq!(CommonImageModel::iter().len(), 20);
assert_eq!(CommonImageModel::iter().len(), 22);
}
#[test]
@@ -82,6 +82,12 @@ pub enum CommonModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
/// Seedream 5.0 Pro as routed via kinovi
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
/// Seedream 5.0 Pro as routed via the kinovi ultra account
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
/// Midjourney without distinguishing a model type or version
#[serde(rename = "midjourney")]
@@ -287,6 +293,8 @@ impl CommonModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -398,6 +406,8 @@ impl CommonModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -513,6 +523,8 @@ impl CommonModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Seedream5p0ProUltra,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -625,6 +637,8 @@ impl CommonModelType {
Self::Seedream4 => CommonModelClass::Image,
Self::Seedream4p5 => CommonModelClass::Image,
Self::Seedream5Lite => CommonModelClass::Image,
Self::Seedream5p0Pro => CommonModelClass::Image,
Self::Seedream5p0ProUltra => CommonModelClass::Image,
Self::Midjourney => CommonModelClass::Image,
Self::MidjourneyV6 => CommonModelClass::Image,
Self::MidjourneyV6p1 => CommonModelClass::Image,
@@ -747,6 +761,8 @@ mod tests {
assert_serialization(CommonModelType::Seedream4, "seedream_4");
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonModelType::Seedream5p0ProUltra, "seedream_5p0_pro_u");
assert_serialization(CommonModelType::Midjourney, "midjourney");
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -852,6 +868,8 @@ mod tests {
assert_eq!(CommonModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(CommonModelType::Seedream5p0ProUltra.to_str(), "seedream_5p0_pro_u");
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -961,6 +979,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedream_4").unwrap(), CommonModelType::Seedream4);
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro_u").unwrap(), CommonModelType::Seedream5p0ProUltra);
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
@@ -1042,7 +1062,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 96);
assert_eq!(variants.len(), 98);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -1069,6 +1089,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0ProUltra));
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
@@ -439,6 +439,68 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream5p0Pro,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 5.0 Pro".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(14),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
resolution_options: Some(vec![
CommonResolution::OneK,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
batch_size_min: Some(1),
batch_size_max: Some(8),
batch_size_default: Some(1),
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream5p0ProUltra,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 5.0 Pro Ultra".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(14),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
resolution_options: Some(vec![
CommonResolution::OneK,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
batch_size_min: Some(1),
batch_size_max: Some(8),
batch_size_default: Some(1),
..Default::default()
});
models
}
@@ -484,6 +546,14 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
model: CommonImageModel::Seedream5Lite,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream5p0Pro,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream5p0ProUltra,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::NanoBanana,
overrides: None,
@@ -95,6 +95,7 @@ use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
@@ -130,6 +131,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f
use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::*;
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::*;
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::*;
use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::*;
@@ -353,6 +355,9 @@ use artcraft_api_defs::analytics::log_active_user::*;
use artcraft_api_defs::credits::get_session_credits::*;
use artcraft_api_defs::subscriptions::get_session_subscription::*;
use artcraft_api_defs::media_file::list_batch_generated_media_files::*;
use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
use artcraft_api_defs::media_file::list_session_project_media_files::*;
use artcraft_api_defs::media_file::project::upload_updated_mood_board_project::*;
use artcraft_api_defs::media_file::project::upload_new_mood_board_project::*;
@@ -495,6 +500,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler,
crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler,
crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler,
crate::http_server::endpoints::media_files::list::by_type::list_session_splat_media_files_handler::list_session_splat_media_files_handler,
crate::http_server::endpoints::media_files::list::list_session_project_media_files_handler::list_session_project_media_files_handler,
crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::search_featured_media_files_handler,
crate::http_server::endpoints::media_files::search::search_session_media_files_handler::search_session_media_files_handler,
@@ -634,6 +641,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
@@ -678,6 +686,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler,
crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler,
crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler,
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
@@ -717,6 +726,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
components(schemas(
DataboxDashboardEntry,
ListDataboxDashboardsResponse,
ModeratorGetUserSpendSummaryResponse,
UserSpendSummaryView,
ModeratorReengagementListResponse,
@@ -1124,7 +1135,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListPinnedWeightsSuccessResponse,
ListSessionJobsItem,
ListSessionJobsQueryParams,
ListSessionMeshMediaFilesSuccessResponse,
ListSessionProjectMediaFilesSuccessResponse,
ListSessionSplatMediaFilesSuccessResponse,
ListSessionJobsSuccessResponse,
ListSessionRequestDetailsResponse,
ListSessionResultDetailsResponse,
@@ -1168,6 +1181,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
PinnedMediaFile,
PinnedModelWeightForList,
ProjectMediaFileInfo,
SessionMediaFileInfo,
PromptInfo,
RatingRow,
RedeemBetaKeyRequest,
@@ -1476,6 +1490,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
BulkRemoveSubfoldersSuccessResponse,
ListFolderMediaFilesQueryParams,
ListFolderMediaFilesSuccessResponse,
ListMediaFilesWithoutFolderQueryParams,
ListMediaFilesWithoutFolderSuccessResponse,
BulkAddFolderMediaFilesRequest,
BulkAddFolderMediaFilesSuccessResponse,
BulkRemoveFolderMediaFilesRequest,
@@ -1517,6 +1533,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
SetMediaFileTagsSuccessResponse,
TagDetails,
TagMediaFileListItem,
UnfolderedMediaFileListItem,
))
)]
pub struct ApiDoc;
@@ -10,7 +10,6 @@ use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
use crate::util::generate_api_key::generate_api_key;
@@ -30,7 +29,6 @@ const MAX_DESCRIPTION_LEN: usize = 512;
(status = 200, body = CreateApiKeySuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 403, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
@@ -46,14 +44,6 @@ pub async fn create_api_key_handler(
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// API key creation is gated behind the `api_key` feature flag.
let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
if !feature_flags.can_create_api_key() {
warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str());
return Err(CommonWebError::Forbidden);
}
let name = request.name.trim().to_string();
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
@@ -55,13 +55,7 @@ pub async fn list_folders_handler(
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
}
};
@@ -76,12 +70,16 @@ pub async fn list_folders_handler(
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
// Only hand out a next-page cursor when this page was full. A short page
// means the list is exhausted, and emitting a cursor anyway would make
// clients fetch one guaranteed-empty trailing page.
let maybe_cursor = if rows.len() == limit as usize {
rows.last()
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id))
.transpose()?
} else {
None
};
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
@@ -148,13 +148,7 @@ pub async fn list_folder_media_files_handler(
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
}
};
@@ -169,12 +163,16 @@ pub async fn list_folder_media_files_handler(
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
// Only hand out a next-page cursor when this page was full. A short page
// means the list is exhausted, and emitting a cursor anyway would make
// clients fetch one guaranteed-empty trailing page.
let maybe_cursor = if rows.len() == limit as usize {
rows.last()
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id))
.transpose()?
} else {
None
};
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
@@ -0,0 +1,198 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use serde_derive::Serialize;
use utoipa::ToSchema;
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use artcraft_api_defs::folders::media_files::ListMediaFilesWithoutFolderQueryParams;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use mysql_queries::queries::folders::media_files::list_media_files_without_folder::{
list_media_files_without_folder, ListMediaFilesWithoutFolderArgs,
};
use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "folders_unfoldered_mf";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
// ── Response shape ──
#[derive(Serialize, ToSchema)]
pub struct ListMediaFilesWithoutFolderSuccessResponse {
pub success: bool,
pub media_files: Vec<UnfolderedMediaFileListItem>,
/// Cursor for the next page. Only present when this page was full —
/// a short page means the list is exhausted.
pub maybe_cursor: Option<String>,
}
/// One media file as it appears in the unfoldered list. Same lean shape as
/// the folder / tag list items. Intermediate system files are filtered out
/// by the query, so the flag isn't part of the wire shape.
#[derive(Serialize, ToSchema)]
pub struct UnfolderedMediaFileListItem {
pub token: MediaFileToken,
/// Coarse-grained class (image / video / audio / mesh / splat / ...).
pub media_class: MediaFileClass,
/// Specific format (jpg, png, mp4, glb, etc.) — closer to a MIME type.
pub media_type: MediaFileType,
/// Link to the prompt
pub maybe_prompt_token: Option<PromptToken>,
/// If this file was generated as part of a batch, the batch token —
/// useful for showing "siblings" in the UI.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// Rich CDN links to the media itself (full URL, thumbnail template,
/// video previews when applicable).
pub media_links: MediaLinks,
/// Cover image details. For files that don't carry their own cover
/// image, this still gives the deterministic default-cover spec
/// (image_index + color_index) the frontend uses for placeholders.
pub cover_image: MediaFileCoverImageDetails,
pub maybe_title: Option<String>,
pub maybe_original_filename: Option<String>,
/// Original pixel width / height for image and video files when known.
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
/// Duration for audio and video files, if available. Milliseconds.
pub maybe_duration_millis: Option<u64>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Handler ──
/// Paginated list of the logged-in user's media files that sit in no folder
/// at all (memberships pointing at soft-deleted folders count as
/// unfoldered). Newest first.
///
/// Use `filter_media_class` to scope the results to a single media class.
#[utoipa::path(
get,
tag = "Folders (Media File Management)",
path = "/v1/folders/media_files_without_folder",
params(ListMediaFilesWithoutFolderQueryParams),
responses(
(status = 200, body = ListMediaFilesWithoutFolderSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_media_files_without_folder_handler(
http_request: HttpRequest,
query: Query<ListMediaFilesWithoutFolderQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMediaFilesWithoutFolderSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
}
};
let rows = list_media_files_without_folder(ListMediaFilesWithoutFolderArgs {
owner_user_token: &user_session.user_token,
maybe_filter_media_class: query.filter_media_class,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_media_files_without_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// Only hand out a next-page cursor when this page was full. A short page
// means the list is exhausted, and emitting a cursor anyway would make
// clients fetch one guaranteed-empty trailing page.
let maybe_cursor = if rows.len() == limit as usize {
rows.last()
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id))
.transpose()?
} else {
None
};
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
let media_files = rows.into_iter()
.map(|row| unfoldered_media_file_row_to_list_item(row, media_domain, server_environment))
.collect();
Ok(Json(ListMediaFilesWithoutFolderSuccessResponse {
success: true,
media_files,
maybe_cursor,
}))
}
fn unfoldered_media_file_row_to_list_item(
row: MediaFileListRow,
media_domain: MediaDomain,
server_environment: server_environment::ServerEnvironment,
) -> UnfolderedMediaFileListItem {
let (media_links, cover_image) =
build_media_links_and_cover(&row, media_domain, server_environment);
UnfolderedMediaFileListItem {
token: row.media_file_token,
media_class: row.media_class,
media_type: row.media_type,
maybe_batch_token: row.maybe_batch_token,
media_links,
cover_image,
creator_set_visibility: row.creator_set_visibility,
is_user_upload: row.is_user_upload,
maybe_title: row.maybe_title,
maybe_prompt_token: row.maybe_prompt_token,
maybe_original_filename: row.maybe_origin_filename,
// Schema stores `INT(10)`; widen to u64 for the wire shape.
maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64),
maybe_frame_width: row.maybe_frame_width,
maybe_frame_height: row.maybe_frame_height,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
@@ -2,3 +2,4 @@ pub mod bulk_add_folder_media_files_handler;
pub mod bulk_move_folder_media_files_handler;
pub mod bulk_remove_folder_media_files_handler;
pub mod list_folder_media_files_handler;
pub mod list_media_files_without_folder_handler;
@@ -77,13 +77,7 @@ pub async fn list_subfolders_handler(
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
}
};
@@ -99,12 +93,16 @@ pub async fn list_subfolders_handler(
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
// Only hand out a next-page cursor when this page was full. A short page
// means the list is exhausted, and emitting a cursor anyway would make
// clients fetch one guaranteed-empty trailing page.
let maybe_cursor = if rows.len() == limit as usize {
rows.last()
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id))
.transpose()?
} else {
None
};
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
@@ -141,6 +141,8 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
CommonImageModel::Seedream5p0ProUltra => RouterImageModel::Seedream5p0ProUltra,
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
@@ -174,9 +174,9 @@ fn db_record_to_response_payload(
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
/// If the job is currently running, this is how long it has been running in seconds.
/// This is heuristic estimate since we don't record the precise start time across runs.
/// If the job's updated_at timestamp is updated elsewhere, then this is not accurate at all.
// If the job is currently running, this is how long it has been running in seconds.
// This is heuristic estimate since we don't record the precise start time across runs.
// If the job's updated_at timestamp is updated elsewhere, then this is not accurate at all.
let maybe_current_execution_duration_seconds = match record.status {
JobStatusPlus::Started => {
let now = Utc::now();
@@ -215,15 +215,11 @@ pub async fn batch_get_media_files_handler(
.await?;
let mut show_deleted_results = false;
let mut is_moderator = false;
if let Some(user_session) = maybe_user_session {
// NB: Moderators can see deleted results.
// Original creators cannot see them (unless they're moderators!)
show_deleted_results = user_session.can_delete_other_users_tts_results;
// Moderators get to see all the fields.
is_moderator = user_session.can_delete_other_users_tts_results
|| user_session.can_edit_other_users_tts_models;
}
let tokens = query.tokens.iter()
@@ -220,15 +220,11 @@ pub async fn get_media_file_handler(
.await?;
let mut show_deleted_results = false;
let mut is_moderator = false;
if let Some(user_session) = maybe_user_session {
// NB: Moderators can see deleted results.
// Original creators cannot see them (unless they're moderators!)
show_deleted_results = user_session.can_delete_other_users_tts_results;
// Moderators get to see all the fields.
is_moderator = user_session.can_delete_other_users_tts_results
|| user_session.can_edit_other_users_tts_models;
}
let media_domain = get_media_domain(&http_request);
@@ -0,0 +1,139 @@
//! Shared logic for the by-class session media file list endpoints
//! (`/v1/media_files/mesh/list`, `/v1/media_files/splat/list`).
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::HttpRequest;
use log::{error, warn};
use artcraft_api_defs::common::responses::pagination_cursors::PaginationCursors;
use artcraft_api_defs::media_file::list::by_type::list_session_common::{ListSessionMediaFilesByTypeQueryParams, SessionMediaFileInfo};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use mysql_queries::queries::media_files::list::list_session_media_files_by_class::{list_session_media_files_by_class, ListSessionMediaFilesByClassArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details_builder::MediaFileCoverImageDetailsBuilder;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const DEFAULT_PAGE_SIZE: usize = 100;
const MAX_PAGE_SIZE: usize = 1000;
/// List the session user's media files of a single class (paginated).
/// The shared core of the mesh and splat list handlers.
pub async fn list_session_media_files_of_class(
http_request: &HttpRequest,
query: &ListSessionMediaFilesByTypeQueryParams,
server_state: &Arc<ServerState>,
media_class: MediaFileClass,
) -> Result<(Vec<SessionMediaFileInfo>, PaginationCursors), CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("MySql pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(
http_request,
&server_state.session_checker,
&mut *mysql_connection,
).await?;
let limit = query.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.min(MAX_PAGE_SIZE);
let sort_ascending = query.sort_ascending.unwrap_or(false);
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
let maybe_cursor_id = if let Some(cursor) = query.cursor.as_deref() {
Some(server_state.sort_key_crypto.decrypt_id(cursor)?)
} else {
None
};
let results_page = list_session_media_files_by_class(ListSessionMediaFilesByClassArgs {
user_token: user_session.get_user_token(),
media_class,
limit,
maybe_cursor_id,
cursor_is_reversed,
sort_ascending,
mysql_executor: &mut *mysql_connection,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("List session {:?} media files query error: {:?}", media_class, err);
CommonWebError::from_error(err)
})?;
let maybe_next = if let Some(id) = results_page.last_id {
Some(server_state.sort_key_crypto.encrypt_id(id)?)
} else {
None
};
let maybe_previous = if let Some(id) = results_page.first_id {
Some(server_state.sort_key_crypto.encrypt_id(id)?)
} else {
None
};
let media_domain = get_media_domain(http_request);
let results = results_page.records.into_iter()
.map(|record| {
let public_bucket_path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
SessionMediaFileInfo {
media_links: MediaLinksBuilder::from_media_path_and_env(
media_domain,
server_state.server_environment,
&public_bucket_path,
),
cover_image: MediaFileCoverImageDetailsBuilder::from_optional_db_fields(
&record.token,
media_domain,
server_state.server_environment,
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
),
maybe_creator_user: UserDetailsLightBuilder::from_optional_db_fields_owned(
record.maybe_creator_user_token,
record.maybe_creator_username,
record.maybe_creator_display_name,
record.maybe_creator_gravatar_hash,
),
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
creator_set_visibility: record.creator_set_visibility,
maybe_prompt_token: record.maybe_prompt_token,
maybe_title: record.maybe_title,
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
let pagination = PaginationCursors {
maybe_next,
maybe_previous,
cursor_is_reversed,
};
Ok((results, pagination))
}
@@ -0,0 +1 @@
pub mod list_session_common;
@@ -0,0 +1,44 @@
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use artcraft_api_defs::media_file::list::by_type::list_session_common::ListSessionMediaFilesByTypeQueryParams;
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::ListSessionMeshMediaFilesSuccessResponse;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::list::by_type::common::list_session_common::list_session_media_files_of_class;
use crate::state::server_state::ServerState;
/// List the session user's mesh files (`media_class = 'mesh'`), paginated.
#[utoipa::path(
get,
tag = "Media Files",
path = "/v1/media_files/mesh/list",
params(ListSessionMediaFilesByTypeQueryParams),
responses(
(status = 200, description = "List the session user's mesh files", body = ListSessionMeshMediaFilesSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn list_session_mesh_media_files_handler(
http_request: HttpRequest,
query: Query<ListSessionMediaFilesByTypeQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListSessionMeshMediaFilesSuccessResponse>, CommonWebError> {
let (results, pagination) = list_session_media_files_of_class(
&http_request,
&query,
&server_state,
MediaFileClass::Mesh,
).await?;
Ok(Json(ListSessionMeshMediaFilesSuccessResponse {
success: true,
results,
pagination,
}))
}
@@ -0,0 +1,44 @@
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use artcraft_api_defs::media_file::list::by_type::list_session_common::ListSessionMediaFilesByTypeQueryParams;
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::ListSessionSplatMediaFilesSuccessResponse;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::list::by_type::common::list_session_common::list_session_media_files_of_class;
use crate::state::server_state::ServerState;
/// List the session user's gaussian splat files (`media_class = 'splat'`), paginated.
#[utoipa::path(
get,
tag = "Media Files",
path = "/v1/media_files/splat/list",
params(ListSessionMediaFilesByTypeQueryParams),
responses(
(status = 200, description = "List the session user's splat files", body = ListSessionSplatMediaFilesSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn list_session_splat_media_files_handler(
http_request: HttpRequest,
query: Query<ListSessionMediaFilesByTypeQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListSessionSplatMediaFilesSuccessResponse>, CommonWebError> {
let (results, pagination) = list_session_media_files_of_class(
&http_request,
&query,
&server_state,
MediaFileClass::Splat,
).await?;
Ok(Json(ListSessionSplatMediaFilesSuccessResponse {
success: true,
results,
pagination,
}))
}
@@ -0,0 +1,3 @@
pub mod common;
pub mod list_session_mesh_media_files_handler;
pub mod list_session_splat_media_files_handler;
@@ -51,15 +51,11 @@ pub async fn list_batch_generated_redux_media_files_handler(
.await?;
let mut show_deleted_results = false;
let mut is_moderator = false;
if let Some(user_session) = maybe_user_session {
// NB: Moderators can see deleted results.
// Original creators cannot see them (unless they're moderators!)
show_deleted_results = user_session.can_delete_other_users_tts_results;
// Moderators get to see all the fields.
is_moderator = user_session.can_delete_other_users_tts_results
|| user_session.can_edit_other_users_tts_models;
}
// NB(bt,2024-03-24): I'm sorry, this is gross. We're not respecting sorting, input ordering,
@@ -1,3 +1,4 @@
pub mod by_type;
pub mod list_batch_generated_redux_media_files_handler;
pub mod list_featured_media_files_handler;
pub mod list_media_files_by_batch_token_handler;
@@ -259,7 +259,6 @@ pub async fn upload_audio_media_file_handler(
};
let mut maybe_duration_millis = None;
let mut maybe_codec_name = None;
if do_audio_decode {
let basic_info = decode_basic_audio_bytes_info(
@@ -272,7 +271,6 @@ pub async fn upload_audio_media_file_handler(
})?;
maybe_duration_millis = basic_info.duration_millis;
maybe_codec_name = basic_info.codec_name;
}
// ==================== OTHER FILE METADATA ==================== //
@@ -170,7 +170,6 @@ pub async fn process_upload_media_file(
let file_size_bytes = bytes.len();
let mut maybe_duration_millis = None;
let mut maybe_codec_name = None;
let mut media_file_type = None;
let mut is_spz = false;
@@ -258,7 +257,6 @@ pub async fn process_upload_media_file(
})?;
maybe_duration_millis = basic_info.duration_millis;
maybe_codec_name = basic_info.codec_name;
}
}
@@ -367,6 +365,7 @@ pub async fn process_upload_media_file(
.creator_set_visibility(creator_set_visibility)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&hash)
.maybe_title(upload_media_request.title.as_deref())
.maybe_origin_filename(upload_media_request.file_name.as_deref())
@@ -244,20 +244,16 @@ fn keep_only_largest_pmx_file(mut entries: Vec<PmxZipEntryDetail>) -> Result<Vec
// Some zip files have entries with useless leading directories. This will remove them.
fn remove_useless_leading_directories(mut entries: Vec<PmxZipEntryDetail>) -> Result<Vec<PmxZipEntryDetail>, PmxError> {
let mut maybe_parent_directory_to_remove = None;
{
let maybe_parent_directory_to_remove = {
let pmx_entries = entries.iter()
.filter(|entry| entry.is_pmx)
.collect::<Vec<&PmxZipEntryDetail>>();
match pmx_entries.get(0) {
None => return Err(PmxError::NoPmxFile),
Some(pmx_file) => {
maybe_parent_directory_to_remove = pmx_file.enclosed_name.parent().map(|p| p.to_path_buf());
}
Some(pmx_file) => pmx_file.enclosed_name.parent().map(|p| p.to_path_buf()),
}
}
};
if let Some(parent) = maybe_parent_directory_to_remove {
info!("Common parent: {:?}", parent);
@@ -291,8 +291,8 @@ pub async fn upload_new_video_media_file_handler(
// Tempdirs we need to keep alive across the upload step. They get dropped at
// function exit which cleans up the on-disk files.
let mut transcode_tempdir_ref = None;
let mut resample_tempdir_ref = None;
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
@@ -324,7 +324,7 @@ pub async fn upload_new_video_media_file_handler(
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
transcode_tempdir_ref = Some(transcode_tempdir);
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
@@ -374,7 +374,7 @@ pub async fn upload_new_video_media_file_handler(
})?;
final_upload_file_path = video_output_path;
resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// True if we touched the user's file in any way (transcoded, resampled, or both).
@@ -4,7 +4,7 @@ use crate::state::server_state::ServerState;
pub const ALPHA_COOKIE_NAME : &str = "enable-alpha";
pub fn alpha_cookie(server_state: &ServerState) -> Cookie {
pub fn alpha_cookie(server_state: &ServerState) -> Cookie<'_> {
Cookie::build(ALPHA_COOKIE_NAME, "true")
.domain(&server_state.env_config.cookie_domain)
.secure(server_state.env_config.cookie_secure) // HTTPS-only
@@ -0,0 +1 @@
pub mod moderator_list_databox_dashboards_handler;
@@ -0,0 +1,67 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use serde::Serialize;
use utoipa::ToSchema;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
#[derive(Serialize, ToSchema)]
pub struct ListDataboxDashboardsResponse {
pub success: bool,
/// Configured Databox datawalls, in display order.
pub databoards: Vec<DataboxDashboardEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct DataboxDashboardEntry {
/// Human-readable dashboard name.
pub name: String,
/// Databox datawall id.
pub id: String,
}
/// List the Databox metrics dashboards configured for the admin dashboard.
/// Dashboards whose env vars aren't set are omitted.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/dashboards/databox",
responses(
(status = 200, description = "Success", body = ListDataboxDashboardsResponse),
(status = 401, description = "Not authorized", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_list_databox_dashboards_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListDataboxDashboardsResponse>, CommonWebError> {
require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let databox = &server_state.dashboards.databox;
let databoards = [
("DAUs", databox.daus_id.as_ref()),
("Daily Generations", databox.daily_generations_id.as_ref()),
]
.into_iter()
.filter_map(|(name, maybe_id)| {
maybe_id.map(|id| DataboxDashboardEntry {
name: name.to_string(),
id: id.clone(),
})
})
.collect();
Ok(Json(ListDataboxDashboardsResponse {
success: true,
databoards,
}))
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod dashboards;
pub mod debug_logs;
pub mod info;
pub mod ip_bans;

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