Compare commits

...
Author SHA1 Message Date
Tanish Manku f5b80ea982 Dev infra: local filesystem uploads + fully-local pagescene media
Closes the two remaining fully-local-stack seams found after the last round.

Uploads (previously every upload endpoint returned 500 with dummy S3 creds -
the bucket put happens before the DB insert and rust-s3 is fail-on-err):
- LegacyBucketClient gains an internal backend enum (S3 unchanged | LocalDir)
  and a create_local(root, bucket_name, root_prefix) constructor. All upload/
  download methods route LocalDir to the filesystem; bucket_name() reports the
  configured name. Objects map disk = root + rooted object path via the new
  pub local_object_disk_path (rejects ".." and backslash segments - PMX zips
  feed user-controlled names), unit-tested. The fake-generation resolver now
  uses the same function instead of its private copy so the layouts can't
  drift.
- build_dependencies: when SERVER_ENVIRONMENT=Development and
  LOCAL_MEDIA_ROOT is set, all three bucket clients are constructed in local
  mode (private/gc have zero callers in storyteller-web, so this is purely
  uniform); the server_environment read moved above client construction.
  Uploads land under .devstack/media and are immediately viewable through
  the existing /media mount + CDN_BASE_URL - no S3/R2/MinIO.

Frontend (pagescene was the only surface actually broken against a local
backend; moodboard/library/create-* already consume media_links correctly):
- The vite /__cdn dev proxy assumed https upstreams, so localhost media
  (http) died on a TLS handshake with a 502 - breaking every pagescene
  GLB/splat/animation fetch. fetchAsset no longer proxies localhost at all
  (the dev backend sends CORS for any localhost origin), and the proxy
  itself now picks http for localhost hosts as a backstop.
- GetCdnOrigin() returns the API host when it is localhost (the local
  backend serves media itself; CDN origin == API origin), production CDN
  otherwise. The pagescene adapter's getCdnUrl skips the Cloudflare
  /cdn-cgi/image resize segment on localhost - the local backend has no
  resizer, and dev serves originals anyway.
- vite /v1 fallback proxy now targets http://localhost:12345 instead of
  production, so nothing can silently fall back to prod traffic in dev;
  fixed the stale main.tsx comment claiming setDevelopment() is disabled.

Docs: seams table updated (uploads + vite proxy + GetCdnOrigin closed;
API_TARGETS/SegmentationApi noted as dead code; the earlier moodboard claim
was wrong - it never had a hardcoded host), LOCAL_MEDIA_ROOT now documents
the storage mode, verification checklist gains the upload check.

Verified E2E on this machine: POST /v1/media_files/upload/image (multipart,
real endpoint) returns success, the object lands under .devstack/media at
the canonical fan-out path, lists in the library with media_class=image,
and serves 200 image/jpeg from http://localhost:12345/media/...;
cloud_storage unit tests pass; storyteller-web + webapp dev server boot
clean with "Local filesystem storage mode is ON".
2026-07-29 21:10:42 -07:00
Tanish Manku 335015d9e2 Dev infra: fully-local generation and gallery for the dev stack
Closes the two gaps found smoke-testing the local stack: an empty gallery
(zero media_files rows, media URLs hardcoded to the remote r2.dev bucket)
and generation flows that could not complete (omni_gen calls providers
synchronously in the handler, so dummy keys meant a 500 after billing;
the webapp blocked submits earlier anyway because signup grants no wallet).

Backend (all development-only):
- CDN_BASE_URL env override in cdn_link.rs (host-only, validated loudly;
  precedence over the compiled-in hosts for every domain/env) and a
  dev-gated actix-files mount serving LOCAL_MEDIA_ROOT/media at /media,
  mirroring the public bucket layout - media URLs in API responses resolve
  against the backend itself
- DEV_FAKE_GENERATION flag on ServerState (easyenv bool, forced off outside
  Development): the omni_gen image/video pipelines skip the external
  provider call and return a synthetic Fal enqueue (external id fake_<uuid>),
  so billing, prompt insert, and the pending generic_inference_jobs row all
  stay real
- dev_fake_generation_resolver_thread: polls pending fake_ jobs (new
  runtime-query list_pending_fake_generation_jobs in mysql_queries) and
  after DEV_FAKE_GENERATION_RESOLVE_SECS (default 6s) marks them
  complete_success with a media_files row backed by a test_data asset
  copied under LOCAL_MEDIA_ROOT (mp4 results get -thumb.jpg/-thumb.gif
  siblings since the thumbnail job never runs locally), or complete_failure
  via the existing simulate_artcraft_failure prompt triggers (category word
  supported, e.g. "simulate_artcraft_failure face_not_detected")

Bootstrap/seeds (PS + bash kept in lockstep):
- seed_demo_user seeds an artcraft wallet (DEMO_CREDITS, default 100k
  banked + ledger rows; idempotent top-up) - signup creates no wallet and
  the webapp gates generate on balance, which is why submits went nowhere
- seed_demo_user also seeds 6 gallery images (test_data jpgs) with objects
  under .devstack/media in the exact bucket fan-out layout
- fixed the already-exists detection in seed_demo_user: create_account
  returns error_type, not error_code, so re-runs died on UsernameTaken
- generated secrets env gains DEV_FAKE_GENERATION/CDN_BASE_URL/
  LOCAL_MEDIA_ROOT (appended to existing files on bootstrap re-runs);
  stale "providers are only contacted by workers" comments corrected
- doctor checks wallet balance, media rows, and the new secrets keys

Docs: dev_setup_local_stack.md documents the new flags, corrects the
sync-provider-call and upload-500 claims, updates the seams table and
verification checklist; storyteller_web CLAUDE.md video model examples
fixed (seedance_2p0_fast_g -> seedance_2p0_fast).

Verified E2E on this machine: gallery lists 8 items on localhost URLs;
image and video submits go pending ~6s then complete with locally-served
results (mp4 + gif preview 200); failure trigger yields complete_failure
with category face_not_detected; credits genuinely deducted (100000 ->
99932); doctor 0 failures / 0 warnings.
2026-07-29 20:48:31 -07:00
Tanish Manku f23a6df80b Dev infra: harden Windows bootstrap after two adversarial debug passes
Pass 1 (correctness sweep):
- All native invocations (mysql, mysqld init, winget, curl downloads,
  cargo run, seed curl) now go through Invoke-Native, so a caller capturing
  the scripts' output can never turn ordinary stderr (warnings, progress)
  into a terminating NativeCommandError under Stop preference
- Provisioning DDL feeds through a temp file + `source` and prints the mysql
  output when it fails (with the MYSQL_ROOT_PASSWORD hint)
- Get-MySqlCount distinguishes "table empty" (0) from "query failed" (-1);
  bootstrap and doctor no longer misread a failed COUNT as unseeded
- diesel_cli that is present but broken (moved libmysql.dll) is detected via
  an exit-code probe and reinstalled with --force
- Shared Add-BuildToolsToSession maps winget install dirs + LIBCLANG_PATH in
  bootstrap, doctor, and run_backend_dev (cargo run may recompile); the tools
  check maps PATH before probing so re-runs skip winget entirely
- my.ini values quoted (clone paths with spaces); service-start timeouts
  point at the .devstack log files

Pass 2 (live retest matrix, all green on this machine):
- Full re-run with output captured in-PowerShell: exit 0, no NativeCommandError
- Wiped-state rebuild (mysql-data/my.ini/redis/secrets deleted): re-init,
  re-provision, 281 migrations replayed, seeds + secrets regenerated, backend
  booted, demo user recreated via /v1/create_account, doctor 0 fail / 0 warn
- Failure paths: seed without backend and doctor without services both die
  cleanly with actionable messages and exit 1; stop_dev_services frees both
  ports gracefully
2026-07-28 22:37:03 -07:00
Tanish Manku 345cbe766a Dev infra: Windows-first bootstrap - portable MySQL/Redis stack, no WSL required
Adds script/bootstrap/windows/*.ps1 (PowerShell 5.1-compatible) as the primary
bootstrap path, validated end-to-end on a real Windows 10 machine:

- Portable stack under gitignored .devstack/: MySQL 8.4.11 (official
  cdn.mysql.com zip, --initialize-insecure, bound to 127.0.0.1) and Redis
  5.0.14.1 (tporadowski build, REDIS_ZIP_URL-overridable) run as plain user
  processes - no admin, no services, no WSL, no Docker
- bootstrap_dev_stack.ps1: winget-installs missing build tools (Strawberry
  Perl, NASM, LLVM; wreq's BoringSSL + aws-lc-sys need them), compiles
  diesel_cli against the portable MySQL's own client lib
  (MYSQLCLIENT_LIB_DIR), runs all 281 migrations, seeds roles/badges
  idempotently, generates the dummy secrets env (plus TEMP_DIR for Windows),
  builds storyteller-web, installs frontend deps
- start/stop_dev_services.ps1, run_backend_dev.ps1, seed_demo_user.ps1,
  dev_stack_doctor.ps1 mirror the bash variants
- Invoke-Native (cmd /c ... 2>&1) wraps long-running native calls so PS 5.1
  cannot turn harmless stderr warnings into terminating NativeCommandErrors
  when output is captured; my.ini uses forward slashes (MySQL escape rules)

Verified on this machine: 281/281 migrations applied, backend built (3m05s)
and booted on :12345 with dummy secrets, POST /v1/create_account +
/v1/login round-trip succeeded, webapp dev server served on :4201, doctor
reports 0 failures. The bash scripts remain the Linux/WSL2/CI path.
2026-07-28 21:23:06 -07:00
Tanish Manku 537409eb3e Dev infra: fresh-clone bootstrap for the fully-local dev stack (backend + webapp)
Adds script/bootstrap/ — idempotent Ubuntu 22.04+/WSL2/CI automation that takes
a fresh clone to a running local stack with zero production traffic:

- bootstrap_dev_stack.sh: apt packages, MySQL 8 + Redis up, storyteller DB/user,
  281 diesel migrations, idempotent role/badge seeds, generated dummy
  development-secrets.env (19 required-at-boot vars no checked-in config
  supplies), SQLX_OFFLINE backend build, frontend npm install
- run_backend_dev.sh / seed_demo_user.sh: run the API from the repo root and
  create+verify a demo login through the real POST /v1/create_account path
- dev_stack_doctor.sh: PASS/WARN/FAIL diagnosis of every prerequisite

_docs/dev_setup_local_stack.md records the requirements the scripts encode and
corrects stale claims (server reads MYSQL_URL, not DATABASE_URL; repo-root .env
is not loaded by the server; config precedence is first-value-wins;
SQLX_OFFLINE is compile-time only).

.gitattributes now forces LF for *.sh/*.env so Windows clones run under WSL2.
2026-07-28 20:51:19 -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
423 changed files with 28857 additions and 3876 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/windows) - 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"
}
Generated
+31 -1
View File
@@ -34,6 +34,29 @@ dependencies = [
"smallvec",
]
[[package]]
name = "actix-files"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b"
dependencies = [
"actix-http",
"actix-service",
"actix-utils",
"actix-web",
"bitflags 2.11.0",
"bytes",
"derive_more 2.0.1",
"futures-core",
"http-range",
"log",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"v_htmlescape",
]
[[package]]
name = "actix-http"
version = "3.12.1"
@@ -7673,7 +7696,7 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
dependencies = [
"proc-macro-crate 2.0.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -11577,6 +11600,7 @@ checksum = "ef5430c8e36b713e13b48a9f709cc21e046723fe44ce34587b73a830203b533e"
name = "storyteller-web"
version = "0.0.1"
dependencies = [
"actix-files",
"actix-http",
"actix-multipart",
"actix-service",
@@ -13894,6 +13918,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "v_htmlescape"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
[[package]]
name = "value-bag"
version = "1.12.0"
+1
View File
@@ -307,6 +307,7 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
actix-cors = "0.7.1"
actix-files = "0.6.9"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
+1 -1
View File
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://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. |
+294
View File
@@ -0,0 +1,294 @@
# Local Dev Stack — Requirements & Bootstrap
How to run the full ArtCraft dev stack **entirely locally** — the
`storyteller-web` backend plus the `artcraft-webapp` frontend — with no traffic
to production servers. Scope: webapp-first; the Tauri desktop app is a later
phase.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend; npx nx dev artcraft-webapp # terminal 2: webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && npx nx dev artcraft-webapp # terminal 2: webapp on http://localhost:4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------------|---------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|--------------------------------------------------|---------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|----------------------------------|--------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
dummy keys the provider call fails and the submit returns HTTP 500 *after*
billing the wallet (the image pipeline has no refund; video refunds only
Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi orders,
WorldLabs) are worker-driven. `DEV_FAKE_GENERATION` (below) exists so local
submits never reach a real provider.
- **Uploads would fail hard with dummy S3 creds** (every media upload
endpoint uploads to the bucket *before* inserting the DB row, and `rust-s3`
is built with `fail-on-err`) — which is why `LOCAL_MEDIA_ROOT` (below) also
switches the bucket clients into a local filesystem mode: uploads write
under `.devstack/media/` and are immediately viewable via the same `/media`
mount. No S3/R2/MinIO needed.
### Fully-local generation and media (`DEV_FAKE_GENERATION`)
The generated secrets file enables three development-only settings (appended
automatically on bootstrap re-runs for older stacks):
| Env var | Effect |
|-----------------------|----------------------------------------------------------------------------|
| `DEV_FAKE_GENERATION` | omni_gen image/video submits skip the external provider and insert a normal **pending** job with a `fake_…` external id. A resolver thread inside `storyteller-web` completes each one after `DEV_FAKE_GENERATION_RESOLVE_SECS` (default 6s, poll every `DEV_FAKE_GENERATION_POLL_SECS`, default 2s) with a placeholder result copied from `test_data/`. Prompts containing `simulate_artcraft_failure` (or `test_artcraft_failure`) fail instead, optionally with a `FrontendFailureCategory` word in the prompt (e.g. `simulate_artcraft_failure face_not_detected`). Hard-disabled outside `SERVER_ENVIRONMENT=Development` |
| `CDN_BASE_URL` | Overrides the compiled-in CDN hosts in `cdn_link.rs` for all media URLs in API responses. Must be scheme+host only (no path) — set to `http://localhost:12345` so media resolves against the backend itself |
| `LOCAL_MEDIA_ROOT` | Directory the backend serves at `/media` (dev-only actix-files mount), mirroring the public bucket layout: object path `/media/{…}` lives at `{LOCAL_MEDIA_ROOT}/media/{…}`. Also switches all three `LegacyBucketClient`s (public/private/gc) into a **local filesystem mode**, so every media upload endpoint writes here instead of S3/R2 — uploads work with dummy cloud creds. The bootstrap points it at `.devstack/media/` |
With all three set, the full loop works offline: submit → pending job →
polling (`/v1/jobs/session`) → success with a `media_files` row whose URL the
backend itself serves — or a failed job with a real failure category. Credits
are genuinely deducted from the seeded wallet (no refund on fake results —
the demo wallet is large enough not to care).
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: `seed_demo_user` also seeds a handful of `media_files` image
rows (from `test_data/image/`) with their objects copied under
`.devstack/media/`, so the library isn't empty on a fresh stack. Rows mirror
what a real image upload writes (`media_class='image'` matters — the
library filters on it, and the column default `'unknown'` is invisible in
every tab).
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **In dev the webapp already targets the local backend**: `src/main.tsx`
calls `StorytellerApiHostStore.setDevelopment()` under
`import.meta.env.DEV`, pointing all API traffic at
`http://localhost:12345`. (The adjacent comment claiming this is disabled is
stale — the call is live, and it bypasses the Vite `/v1` →
`api.storyteller.ai` proxy.) So: local backend running → fully local stack;
local backend **not** running → dev webapp is broken, not silently on prod.
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
Cleaning these up is the next phase of the infra work; the bootstrap does not
change runtime behavior:
| Seam | Today |
|-----------------------------------------------|--------------------------------------------------------------|
| Media/CDN URLs in API responses | **Closed** when `CDN_BASE_URL` + `LOCAL_MEDIA_ROOT` are set (the bootstrap default): media URLs point at the backend's own `/media` mount. Unset, the compiled-in `cdn_link.rs` constants still point at the public `pub-….r2.dev` bucket |
| Media uploads | **Closed** when `LOCAL_MEDIA_ROOT` is set: the bucket clients run in local filesystem mode and uploads land under `.devstack/media/`. Unset (or non-Development), uploads still 500 with dummy creds |
| Vite `/v1` proxy in webapp | **Closed**: now targets `http://localhost:12345` (it was already bypassed in dev by `setDevelopment()`, but the fallback could silently hit production). The `/__cdn` dev proxy also no longer intercepts localhost media (it assumed https and broke pagescene GLB/splat loads against a local backend) |
| `GetCdnOrigin` (pagescene asset thumbnails) | **Closed** for local dev: returns the API host when it's localhost (the local backend serves media itself), production CDN otherwise; the pagescene adapter also skips the Cloudflare `/cdn-cgi/image` resize path on localhost. `enums/Api.ts` `API_TARGETS` and `SegmentationApi` still hardcode production hosts but are dead code (not exported/reachable from the webapp); moodboard never had a hardcoded host — it consumes `media_links` directly |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- Webapp at `http://localhost:4201` can log in as `localdev1`, has credits in
the top bar, and sees the seeded images in the library.
- A generation submit returns `{"success": true, "inference_job_token": …}`,
shows as pending for a few seconds, then completes with a placeholder
result (`DEV_FAKE_GENERATION`) — no external provider is contacted. A
prompt containing `simulate_artcraft_failure` fails with a real error
toast/category instead.
- Uploading an image (e.g. a reference image in the prompt box) succeeds and
the file lands under `.devstack/media/` — no S3/R2 credentials involved.
- Nothing hits `api.storyteller.ai` or the r2.dev CDN during the above
(verify in the browser network tab — media URLs should point at
`http://localhost:12345/media/…`).
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
@@ -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,8 @@ pub enum RouterImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -73,4 +75,9 @@ mod tests {
fn midjourney_8_serializes_to_midjourney_8() {
assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8");
}
#[test]
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
}
}
@@ -26,6 +26,7 @@ use crate::generate::generate_image::providers::artcraft::qwen_edit_2511_angles:
use crate::generate::generate_image::providers::artcraft::seedream_4::build::build_artcraft_seedream_4;
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
@@ -44,6 +45,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 +121,7 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream4) => build_artcraft_seedream_4(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
@@ -141,10 +144,11 @@ 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),
_ => 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,8 @@ use crate::generate::generate_image::providers::artcraft::seedream_4p5::cost::Ar
use crate::generate::generate_image::providers::artcraft::seedream_4p5::request::ArtcraftSeedream4p5RequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost::ArtcraftSeedream5LiteCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
@@ -75,6 +77,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 +96,7 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4(ArtcraftSeedream4RequestState),
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -115,10 +120,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 +143,7 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -162,6 +169,7 @@ impl ImageGenerationRequest {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -207,6 +215,9 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(request) => {
Ok(ArtcraftSeedream5LiteCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -243,6 +254,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 +313,10 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0Pro(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -389,7 +405,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 +421,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,4 @@ pub(crate) mod request_common;
pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
@@ -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
}
}
@@ -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());
}
}
@@ -1,5 +1,5 @@
use std::error::Error;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::anyhow;
@@ -18,11 +18,26 @@ use errors::AnyhowResult;
#[derive(Clone)]
pub struct LegacyBucketClient {
bucket: Bucket,
backend: LegacyBucketBackend,
/// If set, put all files under this root path.
optional_bucket_root: Option<String>,
}
#[derive(Clone)]
enum LegacyBucketBackend {
S3 {
bucket: Bucket,
},
/// Development-only: objects live on the local filesystem under `root`,
/// with the rooted object path (e.g. "/media/a/b/…/file.jpg") mapped
/// segment-by-segment beneath it — the same layout the dev server's
/// static /media mount serves. Lets the local dev stack run every upload
/// endpoint without S3/R2 credentials.
LocalDir {
root: PathBuf,
bucket_name: String,
},
}
#[derive(Debug)]
pub enum LegacyBucketClientError {
@@ -40,10 +55,6 @@ impl Error for LegacyBucketClientError {}
impl LegacyBucketClient {
pub fn bucket_name(&self) -> String {
self.bucket.name().to_string()
}
pub fn create(
access_key: &str,
secret_key: &str,
@@ -85,15 +96,34 @@ impl LegacyBucketClient {
let optional_bucket_root = optional_bucket_root.map(|s| s.to_string());
Ok(Self {
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
backend: LegacyBucketBackend::S3 {
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
},
optional_bucket_root,
})
}
fn get_rooted_object_name(&self, object_name: &str) -> String {
match &self.optional_bucket_root {
None => object_name.to_string(),
Some(root) => format!("{}/{}", root, object_name),
/// Development-only: a "bucket" backed by a local directory (see
/// `LegacyBucketBackend::LocalDir`). `bucket_name` is only reported back
/// via `bucket_name()`; no cloud service is ever contacted.
pub fn create_local(
local_root: impl Into<PathBuf>,
bucket_name: &str,
optional_bucket_root: Option<&str>,
) -> Self {
Self {
backend: LegacyBucketBackend::LocalDir {
root: local_root.into(),
bucket_name: bucket_name.to_string(),
},
optional_bucket_root: optional_bucket_root.map(|s| s.to_string()),
}
}
pub fn bucket_name(&self) -> String {
match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket.name().to_string(),
LegacyBucketBackend::LocalDir { bucket_name, .. } => bucket_name.clone(),
}
}
@@ -103,7 +133,14 @@ impl LegacyBucketClient {
let object_name = self.get_rooted_object_name(object_name);
debug!("Rooted filename for bucket: {}", object_name);
let response = self.bucket.put_object(&object_name, bytes).await?;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object(&object_name, bytes).await?;
let body_bytes = response.bytes();
let code = response.status_code();
@@ -124,7 +161,17 @@ impl LegacyBucketClient {
info!("Filename for bucket: {}", object_name);
let object_name = self.get_rooted_object_name(object_name);
info!("Rooted filename for bucket: {}", object_name);
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
// Content type is derived from the file extension when the dev
// static mount serves it back; nothing to store here.
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
let body_bytes = response.bytes();
let code = response.status_code();
info!("upload code: {}", code);
@@ -145,10 +192,17 @@ impl LegacyBucketClient {
let object_name = self.get_rooted_object_name(object_name);
info!("Rooted filename for bucket: {}", object_name);
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
return write_local_object(root, &object_name, bytes).await;
},
};
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
if let Err(err) = &response {
error!("S3 Upload Error for bucket name {}: {:?}", &self.bucket.name, err);
error!("S3 Upload Error for bucket name {}: {:?}", bucket.name, err);
}
let response = response?;
@@ -204,13 +258,23 @@ impl LegacyBucketClient {
info!("Uploading with content type...");
#[allow(deprecated)]
self.upload_file_with_content_type(&object_path_str, &buffer, content_type).await
}
pub async fn download_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
info!("downloading from bucket: {}", path);
let response = self.bucket.get_object(path).await?;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
let disk_path = local_object_disk_path(root, path)?;
return tokio::fs::read(&disk_path).await
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err));
},
};
let response = bucket.get_object(path).await?;
let bytes = response.bytes();
let code = response.status_code();
@@ -234,24 +298,34 @@ impl LegacyBucketClient {
.map(|s| s.to_string())
.ok_or(anyhow!("could not convert object path to string"))?;
let bucket = match &self.backend {
LegacyBucketBackend::S3 { bucket } => bucket,
LegacyBucketBackend::LocalDir { root, .. } => {
let disk_path = local_object_disk_path(root, &object_path_str)?;
tokio::fs::copy(&disk_path, filesystem_path.as_ref()).await
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err))?;
return Ok(());
},
};
info!("creating file for bucket download: {:?}", filesystem_path.as_ref());
let mut output_file = File::create(filesystem_path).await?;
let result = self.bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
let result = bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
info!("downloading from bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
info!("downloading from bucket (named '{}'), path: {}", bucket.name, &object_path_str);
let status_code = match result {
Ok(status_code) => status_code,
Err(err) => {
return bail!("Error downloading from bucket (named '{}'): {:?}", &self.bucket.name, err)
return bail!("Error downloading from bucket (named '{}'): {:?}", bucket.name, err)
}
};
match status_code {
404 => {
error!("File not found in bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
error!("File not found in bucket (named '{}'), path: {}", bucket.name, &object_path_str);
bail!("File not found in bucket: {}", &object_path_str)
},
_ => {
@@ -261,4 +335,70 @@ impl LegacyBucketClient {
Ok(())
}
fn get_rooted_object_name(&self, object_name: &str) -> String {
match &self.optional_bucket_root {
None => object_name.to_string(),
Some(root) => format!("{}/{}", root, object_name),
}
}
}
/// Map a rooted object path (e.g. "/media/a/b/…/file.jpg") to a disk path
/// under `root`, segment by segment. This is the single source of truth for
/// the local-dir layout — the dev server's static /media mount and the fake
/// generation resolver must agree with it (disk = root + rooted path).
pub fn local_object_disk_path(root: &Path, rooted_object_path: &str) -> anyhow::Result<PathBuf> {
let mut path = root.to_path_buf();
for segment in rooted_object_path.split('/').filter(|s| !s.is_empty()) {
// Object names can come from user-controlled archives (e.g. PMX zip
// entries); never let them escape the root.
if segment == ".." || segment.contains('\\') {
bail!("refusing suspicious object path segment: {}", rooted_object_path);
}
path.push(segment);
}
Ok(path)
}
async fn write_local_object(root: &Path, rooted_object_path: &str, bytes: &[u8]) -> anyhow::Result<()> {
let disk_path = local_object_disk_path(root, rooted_object_path)?;
if let Some(parent) = disk_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&disk_path, bytes).await?;
info!("Wrote local bucket object: {}", disk_path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
mod local_object_disk_path_tests {
use super::*;
#[test]
fn maps_rooted_path_under_root() {
let path = local_object_disk_path(Path::new("/data"), "/media/a/b/file.jpg").unwrap();
assert_eq!(path, Path::new("/data").join("media").join("a").join("b").join("file.jpg"));
}
#[test]
fn ignores_leading_slash_and_empty_segments() {
let with_slash = local_object_disk_path(Path::new("/data"), "/media//x").unwrap();
let without_slash = local_object_disk_path(Path::new("/data"), "media/x").unwrap();
assert_eq!(with_slash, without_slash);
}
#[test]
fn rejects_parent_dir_traversal() {
assert!(local_object_disk_path(Path::new("/data"), "/media/../../etc/passwd").is_err());
}
#[test]
fn rejects_backslash_segments() {
assert!(local_object_disk_path(Path::new("/data"), "/media/a\\b").is_err());
}
}
}
@@ -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,7 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4),
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
@@ -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,8 @@ 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) => true,
_ => false,
};
@@ -0,0 +1,64 @@
use sqlx::{MySqlPool, Row};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
/// A pending job created by the DEV_FAKE_GENERATION short-circuit
/// (external_third_party_id starts with `fake_`), joined with its prompt text
/// so the dev resolver can honor the synthetic-failure prompt triggers.
pub struct PendingFakeGenerationJob {
pub job_token: InferenceJobToken,
/// e.g. "image_generation", "video_generation".
pub inference_category: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_positive_prompt: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
}
/// List pending fake-generation jobs older than `min_age_seconds`, oldest
/// first. Development-only: production never writes `fake_` external ids.
///
/// NB: runtime query (not the compile-time macro) because this dev-only SELECT
/// isn't in the sqlx offline cache.
pub async fn list_pending_fake_generation_jobs(
mysql_pool: &MySqlPool,
min_age_seconds: u32,
limit: u32,
) -> Result<Vec<PendingFakeGenerationJob>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT
j.token AS job_token,
j.inference_category,
j.maybe_prompt_token,
j.maybe_creator_user_token,
p.maybe_positive_prompt
FROM generic_inference_jobs AS j
LEFT JOIN prompts AS p
ON p.token = j.maybe_prompt_token
WHERE j.status = 'pending'
AND j.maybe_external_third_party_id LIKE 'fake\_%'
AND j.created_at <= NOW() - INTERVAL ? SECOND
ORDER BY j.created_at ASC
LIMIT ?
"#)
.bind(min_age_seconds)
.bind(limit)
.fetch_all(mysql_pool)
.await?;
let jobs = rows.into_iter()
.map(|row| PendingFakeGenerationJob {
job_token: InferenceJobToken::new(row.get::<String, _>("job_token")),
inference_category: row.get::<String, _>("inference_category"),
maybe_prompt_token: row.get::<Option<String>, _>("maybe_prompt_token")
.map(PromptToken::new),
maybe_positive_prompt: row.get::<Option<String>, _>("maybe_positive_prompt"),
maybe_creator_user_token: row.get::<Option<String>, _>("maybe_creator_user_token")
.map(UserToken::new),
})
.collect();
Ok(jobs)
}
@@ -8,6 +8,7 @@ pub mod job_status;
pub mod kill_generic_inference_jobs;
pub mod list_user_jobs_for_moderation;
pub mod kill_jobs_in_development;
pub mod list_pending_fake_generation_jobs;
pub mod list_session_jobs;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_successfully_done_by_token;
@@ -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;
@@ -127,6 +127,8 @@ pub enum InferenceModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_v6")]
@@ -334,6 +336,7 @@ impl InferenceModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -462,6 +465,7 @@ impl InferenceModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -594,6 +598,7 @@ impl InferenceModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -709,6 +714,7 @@ impl InferenceModelType {
CommonModelType::Seedream4 => Self::Seedream4,
CommonModelType::Seedream4p5 => Self::Seedream4p5,
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
CommonModelType::Midjourney => Self::Midjourney,
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
@@ -847,6 +853,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 +979,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 +1107,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,8 @@ pub enum CommonImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -75,6 +77,7 @@ impl CommonImageModel {
Self::Seedream4 => CommonModelType::Seedream4,
Self::Seedream4p5 => CommonModelType::Seedream4p5,
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
}
@@ -111,6 +114,7 @@ mod tests {
assert_serialization(CommonImageModel::Seedream4, "seedream_4");
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
}
@@ -136,6 +140,7 @@ mod tests {
(CommonImageModel::Seedream4, CommonModelType::Seedream4),
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
];
@@ -164,7 +169,7 @@ mod tests {
#[test]
fn variant_count() {
use strum::IntoEnumIterator;
assert_eq!(CommonImageModel::iter().len(), 20);
assert_eq!(CommonImageModel::iter().len(), 21);
}
#[test]
@@ -82,6 +82,9 @@ pub enum CommonModelType {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
/// Seedream 5.0 Pro as routed via kinovi
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
/// Midjourney without distinguishing a model type or version
#[serde(rename = "midjourney")]
@@ -287,6 +290,7 @@ impl CommonModelType {
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -398,6 +402,7 @@ impl CommonModelType {
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -513,6 +518,7 @@ impl CommonModelType {
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -625,6 +631,7 @@ impl CommonModelType {
Self::Seedream4 => CommonModelClass::Image,
Self::Seedream4p5 => CommonModelClass::Image,
Self::Seedream5Lite => CommonModelClass::Image,
Self::Seedream5p0Pro => CommonModelClass::Image,
Self::Midjourney => CommonModelClass::Image,
Self::MidjourneyV6 => CommonModelClass::Image,
Self::MidjourneyV6p1 => CommonModelClass::Image,
@@ -747,6 +754,7 @@ mod tests {
assert_serialization(CommonModelType::Seedream4, "seedream_4");
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonModelType::Midjourney, "midjourney");
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -852,6 +860,7 @@ mod tests {
assert_eq!(CommonModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -961,6 +970,7 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedream_4").unwrap(), CommonModelType::Seedream4);
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
@@ -1042,7 +1052,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 96);
assert_eq!(variants.len(), 97);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -1069,6 +1079,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
+4 -4
View File
@@ -75,23 +75,23 @@ curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"newuser4","password":"newuser4"}'
# 2. Generate video (GmiCloud Seedance 2.0 Fast Global):
# 2. Generate video (Seedance 2.0 Fast):
# Requires a UUID-format idempotency_token.
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_fast_g",
"model": "seedance_2p0_fast",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}'
# 3. Generate video (GmiCloud Seedance 2.0 Global, 720p):
# 3. Generate video (Seedance 2.0, 720p):
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_g",
"model": "seedance_2p0",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"resolution": "seven_twenty_p",
@@ -167,6 +167,7 @@ tempfile.workspace = true
# https://github.com/durch/rust-s3/pull/254
rust-s3.workspace = true
actix-files.workspace = true
actix-http.workspace = true
actix-multipart.workspace = true
actix-service.workspace = true
@@ -439,6 +439,37 @@ 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
}
@@ -484,6 +515,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
model: CommonImageModel::Seedream5Lite,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream5p0Pro,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::NanoBanana,
overrides: None,
@@ -130,6 +130,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 +354,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 +499,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,
@@ -678,6 +684,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,
@@ -1124,7 +1131,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListPinnedWeightsSuccessResponse,
ListSessionJobsItem,
ListSessionJobsQueryParams,
ListSessionMeshMediaFilesSuccessResponse,
ListSessionProjectMediaFilesSuccessResponse,
ListSessionSplatMediaFilesSuccessResponse,
ListSessionJobsSuccessResponse,
ListSessionRequestDetailsResponse,
ListSessionResultDetailsResponse,
@@ -1168,6 +1177,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
PinnedMediaFile,
PinnedModelWeightForList,
ProjectMediaFileInfo,
SessionMediaFileInfo,
PromptInfo,
RatingRow,
RedeemBetaKeyRequest,
@@ -1476,6 +1486,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
BulkRemoveSubfoldersSuccessResponse,
ListFolderMediaFilesQueryParams,
ListFolderMediaFilesSuccessResponse,
ListMediaFilesWithoutFolderQueryParams,
ListMediaFilesWithoutFolderSuccessResponse,
BulkAddFolderMediaFilesRequest,
BulkAddFolderMediaFilesSuccessResponse,
BulkRemoveFolderMediaFilesRequest,
@@ -1517,6 +1529,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
SetMediaFileTagsSuccessResponse,
TagDetails,
TagMediaFileListItem,
UnfolderedMediaFileListItem,
))
)]
pub struct ApiDoc;
@@ -9,6 +9,34 @@ const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
const FAKEYOU_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
const STORYTELLER_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
// Runtime override for the CDN base, taking precedence over every constant
// above for all domains and environments. The fully-local dev stack sets
// CDN_BASE_URL=http://localhost:12345 so media URLs resolve against the
// backend's own static /media mount (see add_service_routes) instead of the
// shared r2.dev bucket. Unset (production, and dev-against-remote) keeps the
// compiled-in hosts.
//
// The override must be scheme+host only: some consumers append the rooted
// object path with Url::set_path, which would silently drop a path component
// in the base, so a base with a path is rejected loudly instead.
// Leaked once so get_cdn_host can stay &'static str.
static CDN_BASE_URL_OVERRIDE_STR: Lazy<Option<&'static str>> = Lazy::new(|| {
let base = std::env::var("CDN_BASE_URL")
.ok()
.map(|s| s.trim().trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())?;
let url = Url::parse(&base).expect("CDN_BASE_URL must be a valid absolute URL");
if url.path() != "/" && !url.path().is_empty() {
panic!("CDN_BASE_URL must not include a path component (got '{}'): \
media URL builders overwrite the path", url.path());
}
Some(&*Box::leak(base.into_boxed_str()))
});
static CDN_BASE_URL_OVERRIDE: Lazy<Option<Url>> = Lazy::new(|| {
CDN_BASE_URL_OVERRIDE_STR.map(|s| Url::parse(s).expect("validated above"))
});
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
.expect("should never fail"));
@@ -25,6 +53,9 @@ const STORYTELLER_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELL
// TODO(bt,2025-01-31): Perhaps this should be config driven and configurable at runtime instead of hardcoded.
pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnvironment) -> &'static str {
if let Some(base) = *CDN_BASE_URL_OVERRIDE_STR {
return base;
}
match (media_domain, server_environment) {
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN_STR,
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN_STR,
@@ -34,6 +65,9 @@ pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnviron
}
pub fn new_cdn_url(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Url {
if let Some(url) = CDN_BASE_URL_OVERRIDE.as_ref() {
return url.clone();
}
match (media_domain, server_environment) {
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN.clone(),
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN.clone(),
@@ -10,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,7 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
@@ -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

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