Compare commits

...
Author SHA1 Message Date
Tanish Manku 14a9967dbb artcraft: tighten PageDraw generate pipeline against bypass and resource leaks
Four small hardenings flagged during audit:

- handleGenerate now re-checks (!baseImageBitmap && !baseImageInfo.isBlankCanvas)
  alongside the existing mediaToken check, so a programmatic dispatch or
  alternate trigger that bypasses the visual gate can't reach the snapshot
  step against a stale base. baseImageBitmap added to the deps list.

- Generate pipeline worker closes ImageBitmaps in a finally block instead of
  inline after drawImage, so a throw during compositing or PNG encoding can't
  leak ~67MB of bitmap data per failed bake.

- compositeInWorker / maskInWorker wrap worker.postMessage in a synchronous
  try/catch that deletes the pendingRequests entry and rejects the returned
  Promise on failure (e.g. transferable already detached, structured-clone
  refusal). Without this the entry would stay registered forever and the
  Promise would hang.

- setBaseImageInfo(null) now clears baseImageBitmap alongside baseImageInfo
  to keep the store self-consistent (previously baseImageBitmap could
  linger from the prior selection after a programmatic clear).
2026-05-09 13:49:37 -07:00
Tanish Manku bc15d93606 artcraft: clear baseImageBitmap synchronously when starting a new base load
setBaseImageInfo for a non-blank-canvas image only swapped baseImageInfo and
left baseImageBitmap pointing at the previous image until the new <img>
finished loading. The Generate-button gate (added in 96ce5026b2) checks
baseImageBitmap truthiness, so during that gap the gate let through clicks
that snapshotted the previous image's pixels alongside the new image's
mediaToken — a real wrong-pixels-on-generate bug for inpaint and a stale
composite for the edit path.

Force baseImageBitmap to null at the same set() call that updates
baseImageInfo so the gate now means "loaded for the current info" rather
than "any bitmap is set". The onload handler restores the bitmap when the
new image finishes loading.

Side effect: the canvas briefly renders the previous drawNodes with no
base bitmap until the new image arrives. Acceptable trade for correctness;
revisit if the visual transition feels jarring.
2026-05-09 13:49:37 -07:00
Tanish Manku 456885f618 artcraft: gate PageDraw Generate on a loaded baseImageBitmap and cancel stale base loads
setBaseImageInfo updates baseImageInfo synchronously and only swaps in
baseImageBitmap once the <img> finishes loading. During that gap the canvas
still renders the previous bitmap while baseImageInfo.mediaToken already
points at the new image — clicking Generate would send the new image's token
alongside a mask drawn over the old pixels (inpaint path) or fail silently
(composite path). Worse, with no cancellation, a stale onload from a
previous selection could fire after the user moved on, overwriting
baseImageBitmap (and via applyImageNodes, drawNodes) with the wrong content.

Add a module-level generation counter in SceneState; bump on every
setBaseImageInfo and guard onload/onerror so superseded loads no-op. Wire
PageDraw's <PromptEditor isDisabled> to (!baseImageBitmap &&
!baseImageInfo?.isBlankCanvas) so the button visibly reflects this. Connect
PromptBox2D's existing isDisabled prop to the button's disabled attribute
(it was already checked in handleGenerate but never visualized).

Drop the latest-ref pattern around baseImageBitmap/baseImageInfo in PageDraw;
getCompositeCanvasFile now reads the live snapshot via useSceneStore.getState()
which is the simpler answer for state that already lives in Zustand.
2026-05-09 13:49:37 -07:00
Tanish Manku bc1b1df0d4 PageDraw: Remove extra useEffect
dependency
2026-05-09 13:49:37 -07:00
Tanish Manku 96e0b1065b artcraft: stabilize PageDraw bake hooks via latest-refs to stop cascading recreates
The bake chain (getCompositeCanvasFile → runCompositeBake → getCompositeFile /
scheduleCompositeBake → handleGenerate) used to rebuild every callback in the
chain on every base-image change, because getCompositeCanvasFile listed
baseImageBitmap/baseImageInfo as deps. Reading those via latest-refs makes
getCompositeCanvasFile stable (deps []) and the cascade collapses — listed
deps remain (lint-clean) but never trigger recreates.

Also list baseImageInfo explicitly in the dirty-effect deps so cache
invalidation no longer relies on the cascade's identity changes (baseImageInfo
syncs synchronously in setBaseImageInfo, but baseImageBitmap arrives async
after the image loads, so the two diverge in time).

Wrap getMaskArrayBuffer in useCallback([]) so handleGenerate can list it as a
dep without churning per render.
2026-05-09 13:49:37 -07:00
Tanish Manku 5d8e22afb2 artcraft: drop worker base-bitmap cache so rapid HistoryStack navigation doesn't lag
Each base change triggered an immediate clone+postMessage on top of the
already-debounced pre-bake, costing ~5-15ms per click through history.
Folding the base back into each composite call (atomic per-message) lets
the debounced pre-bake absorb the clone cost on its own schedule and
sidesteps a race where concurrent bakes with different bases could leave
a stale base in the worker-side cache.
2026-05-09 13:49:37 -07:00
Tanish Manku bbdde58b92 artcraft: pre-bake PageDraw composite on idle and cache base bitmap in worker 2026-05-09 13:49:37 -07:00
Tanish Manku 574de2be90 artcraft: send PageDraw composite as canvas_image_media_token to match field semantics 2026-05-09 13:49:36 -07:00
Tanish Manku b72c0ab911 artcraft: stop duplicate generates and offload PageDraw compositing to a worker 2026-05-09 13:49:36 -07:00
Brandon Thomas 13a5dad3a5 beeble improvements, don't 500 on bad input (#1493) 2026-05-09 10:49:05 -04:00
bflatastic f8be6ea4c9 website: hide resolution picker, progress timer 5 mins 2026-05-09 16:35:18 +07:00
bflatastic 5f70776afe website: unified credits chip for mobile and desktop view 2026-05-09 09:28:23 +07:00
bflatastic 5c5a7d1182 credits in top navbar mobile 2026-05-09 09:12:20 +07:00
Brandon Thomas d0e5a05253 frontend api stuff (#1491) 2026-05-08 22:01:21 -04:00
Brandon Thomas 72e052fe7e add redirects for netlify 2026-05-08 21:44:01 -04:00
Brandon Thomas 7471259d28 fix frontend errors (missing vite config, type errors) (#1490) 2026-05-08 21:40:46 -04:00
Brandon Thomas ffde0b06c0 beeble webhook (#1489) 2026-05-08 20:06:09 -04:00
BFlat b4da753346 video background change feature (#1480)
- both app and website
2026-05-09 02:31:54 +07:00
Brandon Thomas 312c603cef remount 2026-05-08 13:45:18 -04:00
Brandon Thomas bdfa31421f beeble endpoint (#1488) 2026-05-08 13:44:41 -04:00
Brandon Thomas c78442cf71 beeble client (#1487) 2026-05-08 12:49:15 -04:00
Brandon ThomasandTanish Manku 58b64b694c Big tauri refactor: new consolidated commands, deprecate and remove duplication (#1486)
Co-authored-by: Tanish Manku <tanish2k09.dev@gmail.com>
2026-05-08 09:29:25 -04:00
BFlat f666e68dcd app: ask location before download (#1483)
- toggle in settings to enable disable ask location on download button
pressed
2026-05-08 16:25:27 +07:00
Brandon Thomas 5a7c253189 fal request logging (#1482) 2026-05-07 21:56:52 -04:00
Brandon Thomas 808beafd4c fal - no api key or webhook url in args (#1481) 2026-05-07 19:28:12 -04:00
bflatastic a10cda3bee website: promptbox body font fix 2026-05-07 21:45:48 +07:00
bflatastic 40f884c776 site font update to align with desktop app 2026-05-07 21:30:33 +07:00
BFlat 9b6b0175ea desktop app: unblock object generations, styling cleanup, model selector (#1478) 2026-05-06 22:51:39 +07:00
BFlat a484b1d1a9 website netlify redirects for support page 2026-05-06 13:01:05 +07:00
BFlat 80d6866fd1 website: support page update (#1477)
- discord first, description change
- navbar support link
- mobile footer icons fix overflow
- scroll to top on page change fix
2026-05-06 12:58:53 +07:00
BFlat ac9a0c2172 show more media info on desktop app lightbox (#1474)
- Lightbox now shows full media metadata: Aspect Ratio, Resolution,
Size, Duration, Audio, and Created (with timestamp).
- Added Author header at the top so the close button no longer overlaps
the Prompt copy button.
- Extracted aspect ratio / resolution / duration formatters into
`@storyteller/common` so the website and desktop app share them.
2026-05-05 21:23:07 +07:00
bflatastic 03c3507841 website: consolidate /session calls into a shared Zustand store 2026-05-05 19:24:23 +07:00
bflatastic 7a7fa63fcc website: mobile menu fixes 2026-05-04 16:42:13 +07:00
bflatastic 30cc15355a support page and mobile nav fix 2026-05-04 16:21:29 +07:00
Brandon Thomas ee16d355f9 moderator debug (#1473) 2026-05-04 02:58:39 -04:00
Brandon Thomas a90a27e45d debug logs (#1472) 2026-05-04 02:18:33 -04:00
Brandon Thomas 769faf3daa fix omni image refs 2026-05-03 16:44:12 -04:00
Brandon Thomas 9d38b45bc2 prompting tool for artcraft (#1470) 2026-05-03 00:26:38 -04:00
Brandon Thomas 4b5e171261 generate cli tool (#1469) 2026-05-02 20:32:29 -04:00
BFlat 47d58bcd80 website: redesign landing (#1446)
full site redesign + animated scroll trigger sections
2026-05-01 15:59:17 +07:00
BFlat 551f4fbae3 batch prompt update 2026-05-01 15:30:20 +07:00
BFlat 122b2464fc batch prompt api and show prompt on job cards (#1464)
- show prompts and job details on the gallery cards
- task queue too
2026-05-01 15:26:46 +07:00
Brandon Thomas 0bd424e2f5 ffffadd multiple job tokens to video gen response (#1463) 2026-05-01 03:58:38 -04:00
Brandon Thomas 2e217d9133 fix multiple video bug (#1462) 2026-05-01 03:32:49 -04:00
Brandon Thomas 00bedb6ae6 incomplete prompt data (#1460) 2026-05-01 02:43:08 -04:00
Brandon Thomas 7bd0b7f910 add quality field to prompts table (#1459) 2026-04-30 19:40:45 -04:00
Tanish Manku f90d928f33 Surface credits-fetch state on top bar coin icon + 60s poll (#1458)
Customers occasionally saw "0 Credits" with no signal that the fetch had
failed silently. Add a status badge on the coin (slow/failed/ recovered,
hidden in the happy path), a 60s background refresh, and gate the
initial fetch on AUTH_STATUS.LOGGED_IN to remove the 1s setTimeout
band-aid that was racing intermediate auth states.
2026-04-30 16:24:49 -07:00
Brandon Thomas 15711af739 postman api 2026-04-30 19:19:01 -04:00
Brandon Thomas 6fc2fa7fe2 remove field 2026-04-30 19:08:29 -04:00
Brandon Thomas f00dc3a006 batch get prompts endpoint (#1457) 2026-04-30 19:07:03 -04:00
Brandon Thomas a57fc7fe50 jobs prompt tokens (#1456) 2026-04-30 17:37:32 -04:00
BFlat 8f9607cc5d promptbox: default both Enter and Shift+Enter to newline (#1454)
Only the submit button triggers generation by default. The "Enter to
generate" toggle (available in both app and website settings top right
nav) still allows opting into Enter-to-submit.
2026-04-30 10:02:10 +07:00
bflatastic e1b8864a8a website: persist pending video batches to localStorage 2026-04-30 08:33:12 +07:00
bflatastic 8dcebec25c for loop removed and using video batch in endpoint 2026-04-30 08:09:22 +07:00
bflatastic f7c9e4ecc7 website: full time stamp in lightbox 2026-04-30 06:59:04 +07:00
Brandon Thomas 633e6bdd6c bump tauri 0.30.0 -> 0.31.0 (#1453) 2026-04-29 18:51:37 -04:00
BFlat 13f1fb4f63 bug fixes for character and video ref uploads on website, show error … (#1451)
…toasts
2026-04-30 01:16:41 +07:00
Brandon Thomas 967828ec2f tauri changes to support artcraft_router v2 (happy horse, seedance 2, etc.) (#1448) 2026-04-29 13:53:30 -04:00
Brandon Thomas 90baabca4b website seedance 21:9 (#1445) 2026-04-29 00:15:51 -04:00
bflatastic 3ede4ef8d1 website: download on site instead of opening new tab 2026-04-29 11:04:42 +07:00
Brandon Thomas 96194ea0d8 new aspect ratio for seedance (#1444) 2026-04-28 22:55:16 -04:00
Brandon Thomas c1f4ae21e2 discrete apis (#1443) 2026-04-28 20:33:34 -04:00
Brandon Thomas 934ab64eac happy horse access (#1442) 2026-04-28 16:56:42 -04:00
Brandon Thomas badad317b8 retry kinovi polling with backoff (#1441) 2026-04-28 16:49:44 -04:00
bflatastic 7f35eec2b4 fix: guard handleGenerate with useRef to close double-submit race 2026-04-28 22:57:27 +07:00
bflatastic 4cb5a713a8 web credits in navbar 2026-04-28 09:50:23 +07:00
BFlat ae8fd30b4b shift enter to generate by default, toggleable via settings panel in app (#1440)
- Pressing **Enter** in any prompt box now adds a new line instead of
submitting - fixes complaints about accidental generations
- **Shift+Enter** is the new submit shortcut
- Desktop app gets an "Enter to generate" toggle in Settings → General
(off by default) for users who prefer the old behavior
- Applies to all prompt boxes in the Artcraft desktop app and the
website
2026-04-26 21:34:08 +07:00
bflatastic d37a732d60 name mappings for happy horse 1.0 2026-04-24 16:15:17 +07:00
bflatastic 726bde7c11 happy horse alibaba icon 2026-04-24 15:48:11 +07:00
Brandon Thomas 1864eedb7a moderator feature flags editing (#1436) 2026-04-24 03:42:57 -04:00
Brandon Thomas 853f596efc add happyhorse behind feature flag (#1435) 2026-04-24 03:06:35 -04:00
bflatastic 5535d50ec3 website: image ref selection limit fixed, gallery item active focus outline blinking fixed 2026-04-24 13:25:17 +07:00
bflatastic 34cd723417 website: persist references after navigation 2026-04-24 13:11:58 +07:00
Brandon Thomas 681f94b245 fix omni seedance characters (#1434) 2026-04-24 00:04:14 -04:00
Brandon Thomas cfa5b211ea kinovi happyhorse (#1433) 2026-04-23 23:24:07 -04:00
Brandon Thomas 8436d42d41 more logging 2026-04-23 19:25:00 -04:00
Brandon Thomas 53acdd544f omni video handler - utilize second pipeline for seedance; various other fixes (#1432) 2026-04-23 19:21:53 -04:00
bflatastic 5ea6172ce8 website fix: mobile remove bottom overlay on error card blocking dismiss button 2026-04-23 13:46:06 +07:00
BFlat 9166148701 website error messages (#1431)
artcraft website now shows error messages on the failed cards and task
queue
2026-04-23 13:43:17 +07:00
Brandon Thomas e6e739e874 move old artcraft_router pipeline (#1430) 2026-04-23 00:29:22 -04:00
Brandon Thomas 4112652c4e artcraft 0.29 -> 0.30 (#1429) 2026-04-22 22:27:13 -04:00
Brandon Thomas 969d7d7609 add seedance fast @ artcraft to artcraft router, update seedance (non-fast): both call omni (#1428) 2026-04-22 21:34:52 -04:00
Brandon Thomas 1f53b6a4cf tauri: seedance 2.0 fast, seedance 2.0 1080p (#1427) 2026-04-22 19:45:58 -04:00
Brandon Thomas 3947eebe7e bump artcraft 0.28 -> 0.29 (#1426) 2026-04-22 19:18:19 -04:00
Brandon Thomas 5eb2abf112 artcraft desktop gpt-image cost estimates + quality arg (#1425) 2026-04-22 19:17:07 -04:00
Brandon Thomas 31a9704faf fix media file list pagination (#1423) 2026-04-22 17:12:06 -04:00
bflatastic 02676680b1 fixed quality field name 2026-04-23 01:56:45 +07:00
BFlat ba19e9ae40 gpt image 2 quality selector (#1422) 2026-04-23 01:37:50 +07:00
Brandon Thomas 00499252a1 gpt-image-2 desktop support for quality (#1421) 2026-04-22 14:18:55 -04:00
Tanish Manku c7b9de7cf9 3D: Fix editor undo/redo keybinds and hotkey signal bugs (#1252)
- Ctrl+Z/Cmd+Z was calling redo() instead of undo(); fixed and added
missing Ctrl+Shift+Z/Cmd+Shift+Z and Ctrl+Y redo bindings
- Wrap all async keybind handlers in try/finally so isProcessing is
always cleared even if undo/redo/copy/paste throws
- Fix === typo in disableHotkeyInput (comparison instead of assignment),
and use full value replacement so the Preact signal update is reactive
2026-04-22 08:24:42 -07:00
Brandon Thomas c2ca096099 desktop frontend support for gpt-image-2 (#1418) 2026-04-22 10:40:47 -04:00
Brandon Thomas a04ee4f76c fix compile error 2026-04-22 10:15:52 -04:00
bflatastic 8bc552c450 add gpt image 2 to name mapping 2026-04-22 18:44:24 +07:00
BFlat 128d3e43c5 reuse gallery modal for the artcraft website and app styling aligned (#1415)
- website to reuse shared app lib's gallery modal for image selection
- styling alignments with the desktop app
- new checkbox shared component
2026-04-22 18:38:02 +07:00
Brandon Thomas bc226fb361 omni gpt image 2 (#1414)
this will enable it in storyteller-web
2026-04-22 01:11:42 -04:00
Brandon Thomas 7817f3af70 fal gpt image 2 (#1413) 2026-04-21 21:53:14 -04:00
Brandon Thomas cf1f4eb8c8 gpt image 2 enum (#1412) 2026-04-21 21:08:28 -04:00
Brandon Thomas 2e6ccf0d00 artcraft v2 router for seedance 2.0 (non-fast) (#1411) 2026-04-21 20:32:41 -04:00
Brandon Thomas a560541b62 import cleanup 2026-04-21 18:43:20 -04:00
Brandon Thomas df4e300119 artcraft_router v2 kinovi seedance 2 fast (#1410) 2026-04-21 18:34:49 -04:00
Brandon Thomas 9bd3726d7a artcraft router redesign (#1409) 2026-04-21 17:45:54 -04:00
BFlat fc0f4b7f5c fix: prevent character mention from matching wrong character 2026-04-21 23:20:00 +07:00
Brandon Thomas 94fdd6cb72 rename struct to better enable a state machine flow (#1406) 2026-04-20 18:14:24 -04:00
BFlat 0d235bfe27 gallery card action buttons (#1405)
- Quick actions on gallery cards: Recreate, Share, and Download buttons
now appear on hover directly on every image/video card, so users can act
on content without opening the lightbox first.
- Media type and model badges are larger and restyled for better
readability on thumbnails.
- The library page now uses the same card component as the generation
gallery, so any future card improvements land everywhere at once.
- Library grid tightened from up to 6 columns to a cleaner max of 5 per
row.
- Removed the dotted background on the library page so skeletons and
cards sit on a solid surface.
2026-04-20 23:58:24 +07:00
BFlat 6883b64276 website: unified lightbox, recreate, desktop CTA, prompt fixes (#1404)
## Artcraft website: unified lightbox, recreate, desktop CTA, prompt
fixes

**Lightbox**
- Gallery modal and `/media/:id` page now share one details component -
no more drift.
- Author sticks to the top when you scroll; `X` moved to the details
panel; "Show more" prompt toggle added to the media page.
- Shows aspect ratio, resolution, duration, and audio on/off.

**Recreate (new)**
- Button in the lightbox restores prompt, refs (image / end-frame /
video / audio), model, aspect, resolution, duration, audio toggle, and
input mode from a past generation.
- Routes to Create Image vs Create Video based on the saved model class,
not URL guessing.
- Does not auto-generate.

**Desktop CTA**
- Dismissible banner above the promptbox linking to the desktop app.
Hidden on mobile, reappears 24h after dismissal.

**Model names**
- Model selector uses `full_name` from the OmniGen API so new models
show up correctly without a code change.
- Added Seedance 2.0 Fast to the shared model list for the lightbox
details panel.

**Promptbox `@mentions`**
- Triggers correctly after Chinese, punctuation, or emoji - not just
whitespace.
- Case-insensitive: `@image1` highlights as `@Image1`.
2026-04-20 21:20:27 +07:00
Brandon Thomas 2c9a78b802 rename struct 2026-04-19 22:04:43 -04:00
BFlat 6542834336 fix: video promptbox layout bugs (#1403)
fixes:
- prompt box no longer overlaps bottom buttons or wastes space on big
screens
- long unbroken text wraps instead of escaping the box
- resizing the window updates the layout without needing to refocus
2026-04-20 07:36:47 +07:00
bflatastic 6a2719c742 website download link update 2026-04-20 06:57:24 +07:00
Brandon Thomas 38219a4abf link website omni video jobs to ledger entries for refunds (#1402) 2026-04-19 15:03:20 -04:00
Brandon Thomas 3fc002d793 quick refunds hack for kinovi (#1401)
code is actually solid, but the old omni pipeline itself is a hack on
top of an incomplete artcraft_router, so this will be replaced when
we're done.
2026-04-19 13:51:57 -04:00
Brandon Thomas c0c17b27b5 1080p support (quick) (#1400) 2026-04-18 18:15:35 -04:00
Brandon Thomas b68b8f7d71 simplify artcraft_router (#1399) 2026-04-18 08:40:49 -04:00
Brandon Thomas 3d9b4be7ab rename field (#1398) 2026-04-18 08:10:36 -04:00
Brandon Thomas 489390c881 refacor kinovi generate_video request (#1397) 2026-04-18 07:48:52 -04:00
Brandon Thomas f5338611ac seedance 2.0 resolution options (#1396) 2026-04-17 20:51:17 -04:00
Brandon Thomas 11fc5746ec seedance 1080p (#1395) 2026-04-17 20:31:01 -04:00
Tanish Manku 47383daba8 Experimental: Storyboard page (#1394) 2026-04-17 15:32:04 -07:00
Tanish Manku 9166985418 Experimental Menu: Hidden experimental settings menu via 7x version click (#1393) 2026-04-17 14:17:34 -07:00
BFlat 8fc488551f website: bug fixes (#1391)
## Website bug fixes: Seedance Fast + Characters + Toasts

### Create Video page
- Switching between Seedance and Seedance Fast no longer loses your
attached references
- Seedance Fast now supports Characters (was missing from the UI)
- Characters work in both Keyframe and Reference modes
- Fixed a bug where typing `@Bob2` could accidentally attach an older
character named `@Bob`

### Task queue notifications
- No more ghost "job complete" toasts every time you reload the site
- Cleaner wording: "Image creation complete" instead of "Image complete
— Image Generation"
- Character jobs now say "Character" instead of the generic "Task"

### Toast redesign
- Matches the prompt box style instead of the old bright green
- Success = green circle with a white checkmark, error = red circle with
a white exclamation
- Centered on mobile, stays top-right on desktop
- Message no longer awkwardly wraps onto multiple lines on mobile
2026-04-17 15:39:20 +07:00
Brandon Thomas 3a9cebc1b8 omni character support (#1390) 2026-04-17 03:48:35 -04:00
Brandon Thomas 72a2d956b9 cleanup seedance 2.0 in omni generation (#1389) 2026-04-17 02:23:09 -04:00
Brandon Thomas 62a02c07df artcraft_router: simpler interface based on ownership instead of refs (#1388)
Should have done this all along for ease of development. 

- Gets rid of refs between data structures. 
- Clones won't impact our SLA or performance at our scale, this isn't
systems programming.
2026-04-17 00:04:05 -04:00
Brandon Thomas ec991185c6 website dynamically targets development in dev mode (#1387) 2026-04-16 18:32:57 -04:00
Brandon Thomas fc3423d05e model creator + name metadata for omni endpoints (#1386) 2026-04-16 18:02:00 -04:00
bflatastic 98cd19bd40 website: video thumbnail loading spinner 2026-04-17 01:17:06 +07:00
Tanish Manku 94d9de09cc Fix video aspect ratio icon not updating on gallery recreate (#1384)
getCurrentAspectRatioIcon() was reading from aspectRatioList (useState),
which is only updated on manual picker clicks, not external store
writes. Reading aspectRatio from the store directly fixes the stale
icon.
2026-04-16 09:58:04 -07:00
Brandon Thomas 7d788bff62 omni router seedance 2.0 fast (#1382) 2026-04-16 02:59:50 -04:00
BFlat d0e2ad9269 website: credits purchasable from pricing page (#1381)
- only visible when logged in
2026-04-16 11:35:25 +07:00
BFlat 6d9401c1f1 website: promptbox resizable and expandable via chevron icon button (#1380) 2026-04-16 11:00:12 +07:00
Brandon Thomas 49e49efa3c worldlabs marble 1.1 client library (#1379) 2026-04-15 22:01:13 -04:00
BFlat d51c001835 video ref select video from library support (#1378)
## Video reference library picker

- Added "Pick from library" option to the Video Ref slot, with gallery
filtered to videos
- Added loading state to "Use selected" button in gallery modal
- Enforced seedance-2 limits (max 3 videos / 15s total) for both upload
and library flows, with a deduped video-ref-limit toast so users see a
warning on count overflow, duration overflow, or submitting when already
at max
2026-04-16 07:49:48 +07:00
BFlat f94fc5bf07 Fix @mention tagging in prompts with CJK characters (#1376)
Two bugs were preventing @image / @video / @character references from
being tagged in prompts written with Chinese, Japanese, or Korean text:

1. The mention-highlight regex was built with just the "g" flag, so a
user-typed "@image6" never matched the canonical label "@Image6" and the
reference was rendered as plain text. Make the regex case-insensitive
and do the colorMap lookup via a lowercase-keyed table so "@image6"
still finds the color for "@Image6".

2. detectMention required the character immediately before "@" to match
/\s/, which never matches CJK characters. Typing "@" right after a
Chinese character like "从" therefore never opened the autocomplete
dropdown, leaving users to type the mention by hand (which then hit bug
1). Replace the whitespace check with a "not an ASCII identifier char"
check so CJK, quotes, and punctuation all count as valid word boundaries
before an @mention.
2026-04-16 07:05:21 +07:00
Brandon Thomas f397ad825c worldlabs fixes for nsfw 500ing (#1377) 2026-04-15 20:05:07 -04:00
Brandon Thomas 7eea797883 more logging (endpoint failing without debugging info) (#1373) 2026-04-15 17:49:21 -04:00
bflatastic 73856fca85 loading spinner to wait for video thumbnail to return 2026-04-16 01:48:29 +07:00
bflatastic 3d27c620e4 site video time progress update 2026-04-16 01:35:28 +07:00
Brandon Thomas 062b23d3a0 omni seedance 2 (#1372) 2026-04-15 13:15:20 -04:00
BFlat 129fc3d593 enable omni gen api on website (#1370) 2026-04-15 01:58:56 -04:00
Brandon Thomas ad40798a12 chrome launch script (#1371) 2026-04-15 01:44:39 -04:00
bflatastic 3db8e66204 gallery to clear cache and reset after logout/login with new account 2026-04-14 23:35:52 +07:00
1017 changed files with 76934 additions and 12389 deletions
+19 -1
View File
@@ -42,6 +42,21 @@ then the story, then the fine print.
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
### Ordering within test modules
Put test cases first, helpers last. A reader opening a test file should immediately see
*what* is being tested before *how* the test machinery works.
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions** or **sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't create a sub-module for a single test. Keep nesting to two levels max
(`mod tests { mod category { ... } }`). Shared helpers go in the parent module so
sub-modules can reach them via `use super::*`.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
@@ -53,7 +68,10 @@ then the story, then the fine print.
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer `use` imports over inline fully-qualified paths. Inline qualification is acceptable
only for genuine one-off references where adding an import would be noise, or for types
that collide with std prelude names (`Result`, `Option`, `Error`). If you reference a symbol
more than once, always import it.
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
- When two crates export a type with the same name, alias with a suffix describing the source crate:
`use enums::...::CommonResolution as CommonResolutionEnum;`
+27 -1
View File
@@ -10,10 +10,28 @@
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Group related tests into sub-modules by category (e.g. `mod pricing_tests`, `mod resolution_tests`)
- Only create a sub-module when it has 2+ tests — don't wrap a single test in its own module
- Keep nesting shallow: `mod tests { mod category { ... } }` is fine; a third level is rarely needed
- Shared helpers used by multiple sub-modules belong in the parent `mod tests`, not duplicated in each sub-module
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## Test File Layout
Put test cases first, helpers last. Engineers should see the actual tests before scrolling
to understand helper plumbing.
Within a `mod tests { ... }` block:
1. **Imports** at the top
2. **Constants** (test data IDs, URLs, etc.) — small context that tests reference
3. **Test functions** (`#[test]` / `#[tokio::test]`) grouped by category
4. **Helper functions** (builders, pipeline runners, etc.) at the very bottom
If tests are grouped into sub-modules, the same rule applies within each sub-module:
constants first, then tests, then helpers at the end of that sub-module.
## What to Test
- New error types and their mappings
@@ -21,6 +39,14 @@
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## Imports in Tests
- Import test fixtures and constants with `use`, not inline fully-qualified paths.
Write `use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;` at the top,
then reference `JUNO_AT_LAKE_IMAGE_URL` in test bodies.
- Same rule applies to enum variants used in match arms — import them, don't write
`crate::some::deep::module::MyEnum::Variant` inline.
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -116,9 +126,10 @@
true,
false,
true,
true,
false,
false
]
},
"hash": "fbe482c8987231c3b5d75f2e792d0fdd033de8a7eb821428fa034752c73edfb7"
"hash": "2c98621c08055374fd09502c94ac40bb01d23fcea3f844cceea7446a5c0cb4dc"
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n\n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
@@ -125,7 +125,7 @@
},
{
"ordinal": 12,
"name": "maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory",
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
@@ -135,6 +135,16 @@
},
{
"ordinal": 13,
"name": "maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 14,
"name": "failure_reason",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_result_entity_type",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_result_entity_token",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_result_batch_token",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_model_weights_title",
"type_info": {
"type": "VarString",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_tts_model_title",
"type_info": {
"type": "VarString",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "maybe_voice_conversion_model_title",
"type_info": {
"type": "VarString",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "maybe_tts_public_bucket_path",
"type_info": {
"type": "VarString",
@@ -214,7 +224,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "maybe_voice_conversion_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -224,7 +234,7 @@
}
},
{
"ordinal": 22,
"ordinal": 23,
"name": "maybe_media_file_public_bucket_directory_hash",
"type_info": {
"type": "VarString",
@@ -234,7 +244,7 @@
}
},
{
"ordinal": 23,
"ordinal": 24,
"name": "maybe_media_file_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -244,7 +254,7 @@
}
},
{
"ordinal": 24,
"ordinal": 25,
"name": "maybe_media_file_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -254,7 +264,7 @@
}
},
{
"ordinal": 25,
"ordinal": 26,
"name": "maybe_assigned_worker",
"type_info": {
"type": "VarString",
@@ -264,7 +274,7 @@
}
},
{
"ordinal": 26,
"ordinal": 27,
"name": "maybe_assigned_cluster",
"type_info": {
"type": "VarString",
@@ -274,7 +284,7 @@
}
},
{
"ordinal": 27,
"ordinal": 28,
"name": "is_keepalive_required",
"type_info": {
"type": "Tiny",
@@ -284,7 +294,7 @@
}
},
{
"ordinal": 28,
"ordinal": 29,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -294,7 +304,7 @@
}
},
{
"ordinal": 29,
"ordinal": 30,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -304,7 +314,7 @@
}
},
{
"ordinal": 30,
"ordinal": 31,
"name": "maybe_first_started_at",
"type_info": {
"type": "Timestamp",
@@ -314,7 +324,7 @@
}
},
{
"ordinal": 31,
"ordinal": 32,
"name": "maybe_successfully_completed_at",
"type_info": {
"type": "Timestamp",
@@ -324,7 +334,7 @@
}
},
{
"ordinal": 32,
"ordinal": 33,
"name": "database_clock",
"type_info": {
"type": "Datetime",
@@ -365,6 +375,7 @@
true,
true,
true,
true,
false,
false,
false,
@@ -373,5 +384,5 @@
false
]
},
"hash": "a2c9e2f61eba666254f8432b3c50a0a7738ee3b3faa77c5281bc8e4af96159b9"
"hash": "6948edca3f06643605695665df51cc94f897e2cb9a2d6906910d58d4d56ee73e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "8f8b64c0af5383ff3baaa8aa2f2aa22cc760addd850d618ce543fe05816d11e0"
}
@@ -0,0 +1,80 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
"Right": 17
},
"nullable": []
},
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
"hash": "c73be10c5677f78f81bc1c29872fd5af889790c3b2a8de27eb8ab64383c638d6"
}
@@ -0,0 +1,355 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n uuid_idempotency_token,\n job_type as `maybe_job_type`,\n product_category as `maybe_product_category`,\n inference_category,\n maybe_external_third_party,\n maybe_external_third_party_id,\n maybe_model_type,\n maybe_model_token,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_inference_args,\n maybe_download_url,\n on_success_result_entity_type,\n on_success_result_entity_token,\n on_success_result_batch_token,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token,\n creator_ip_address,\n creator_set_visibility,\n maybe_wallet_ledger_entry_token,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n status,\n attempt_count as `attempt_count: u16`,\n failure_reason,\n internal_debugging_failure_reason,\n frontend_failure_category,\n maybe_routing_tag,\n assigned_worker,\n assigned_cluster,\n created_at,\n updated_at\nFROM generic_inference_jobs\nWHERE token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "uuid_idempotency_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 144
}
},
{
"ordinal": 2,
"name": "maybe_job_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_product_category",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "inference_category",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_external_third_party",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 7,
"name": "maybe_model_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_model_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_inference_args",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 262140
}
},
{
"ordinal": 11,
"name": "maybe_download_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 4096
}
},
{
"ordinal": 12,
"name": "on_success_result_entity_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 13,
"name": "on_success_result_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 14,
"name": "on_success_result_batch_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_creator_anonymous_visitor_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 17,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 18,
"name": "creator_set_visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 19,
"name": "maybe_wallet_ledger_entry_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 20,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 21,
"name": "status",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 22,
"name": "attempt_count: u16",
"type_info": {
"type": "Short",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 23,
"name": "failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 24,
"name": "internal_debugging_failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 25,
"name": "frontend_failure_category",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 26,
"name": "maybe_routing_tag",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 27,
"name": "assigned_worker",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 28,
"name": "assigned_cluster",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 29,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 30,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "e869f065e6f184abbabae04ecd0da6ea02c0576cc22ee55e47eb91dd34048ef1"
}
+2
View File
@@ -62,6 +62,7 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
@@ -75,3 +76,4 @@ Organize for top-to-bottom reading. Important things first, details later.
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
Generated
+23
View File
@@ -1404,6 +1404,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "beeble_client"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"test_data",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "bigdecimal"
version = "0.3.1"
@@ -11234,6 +11250,7 @@ dependencies = [
"async-stripe 0.26.0",
"async-trait",
"base64 0.22.1",
"beeble_client",
"billing_artcraft_component",
"billing_component",
"bootstrap",
@@ -11469,13 +11486,18 @@ name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"clap 4.5.60",
"easyenv",
"enums",
"errors",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
"uuid 1.22.0",
]
[[package]]
@@ -14793,6 +14815,7 @@ dependencies = [
"serde",
"serde_json",
"strum",
"test_data",
"tokio",
"uuid 1.22.0",
"workspace-build-acceleration",
+2
View File
@@ -16,6 +16,7 @@ members = [
"crates/api_clients/grok_client",
"crates/api_clients/midjourney_client",
"crates/api_clients/muapi_client",
"crates/api_clients/beeble_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
@@ -244,6 +245,7 @@ fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
beeble_client = { path = "crates/api_clients/beeble_client" }
grok_client = { path = "crates/api_clients/grok_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
rootly_config = { path = "crates/schema/config/rootly_config" }
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_quality;
@@ -0,0 +1,8 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Add quality parameter to prompts table.
-- Represented by the enum `CommonQuality` (high, medium, low).
ALTER TABLE prompts
ADD COLUMN maybe_quality VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS debug_logs;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_debug_log_event_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -109,7 +109,7 @@ CREATE TABLE generic_inference_jobs (
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
-- track of all the prompt details, context images, etc.
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
maybe_prompt_token VARCHAR(32) DEFAULT NULL,
-- A migration flag we can control from the backend API service which will tell
-- the worker where to store the results. If false, GCP. If true, AWS.
@@ -204,6 +204,10 @@ CREATE TABLE generic_inference_jobs (
-- off special workers to handle certain jobs.
maybe_routing_tag VARCHAR(64) DEFAULT NULL,
-- Foreign key to the debug_logs table (event_token column).
-- Multiple debug_logs rows can share the same event token.
maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL,
-- ========== JOB SYSTEM DETAILS ==========
-- Jobs begin as "pending", then transition to other states.
@@ -68,6 +68,10 @@ CREATE TABLE prompts (
-- The resolution for the prompt (eg. four_k, 720p, etc.)
maybe_resolution VARCHAR(16) DEFAULT NULL,
-- Represented by the enum `CommonQuality`.
-- The quality setting (eg. high, medium, low)
maybe_quality VARCHAR(16) DEFAULT NULL,
-- The duration in seconds
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
@@ -15,11 +15,12 @@ body:
type: json
content: |
{
"model": "seedream_5_lite",
"model": "gpt_image_2",
"provider": "artcraft",
"generation_mode": {"type": "text_to_image" },
"aspect_ratio": "wide_sixteen_by_nine",
"resolution": "two_k",
"quality": "low",
"image_batch_count": 1
}
order: 2000
@@ -0,0 +1,3 @@
$kind: collection
name: edit
order: 312
@@ -0,0 +1,23 @@
$kind: http-request
name: Beeble SwitchX Edit Video
url: "{{api_host}}/v1/generate/video/edit/beeble_switchx"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
body:
type: json
content: >-
{
"uuid_idempotency_token": "{{$randomUUID}}",
//"source_video_media_token": "m_6vve5gffn2r4rajyvy8z2380x15kqc", // two corgis (dev)
"source_video_media_token": "m_rkgdk7qw0a7gz4mrzfv0q78ax57kbx", // corgi cowboy (dev)
//"source_video_media_token": "m_1k19k6dgdny253zag8g3jqf3bgemss", // corgi watermellon (prod)
"reference_image_media_token": "m_pf310vdhc4v98n7r8mz70bz9geest3" // tornado (dev)
//"reference_image_media_token": "m_z4y097t9pdmbyatf70z0qb8zvfphvm" // cactus (prod)
//"prompt": "Stormy skies, there's a rotating tornado in the background"
}
order: 1778262498846
@@ -15,7 +15,7 @@ body:
type: json
content: |
{
"user_token": "user_jg7ng4kdt2x5y", // newuser946
"user_token": "user_rtdhen9kz73kx", //
"payments_namespace": "artcraft"
}
order: 4100053395280358
@@ -0,0 +1,11 @@
$kind: http-request
name: Batch Get Prompt
url: "{{api_host}}/v1/prompts/prompt_8q8fwcmh93nn8pjb2szeqbxgz"
method: GET
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
order: 2171327660164107
@@ -9,7 +9,7 @@ body:
// hanashi : password (dev)
//"username_or_email": "hanashi",
//"password": "hanashi"
"username_or_email": "newuser2",
"password": "newuser2"
"username_or_email": "newuser4",
"password": "newuser4"
}
order: 1000
+59
View File
@@ -0,0 +1,59 @@
# Rust Workspace
All Rust code lives under `crates/`. The workspace is defined in `Cargo.toml` at the repo root.
## Code Style
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`
- When two crates export the same type name, alias with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std prelude collisions (`Result`, `Option`, `Error`)
## File Layout
Organize for top-to-bottom reading. Important things first, details later.
1. **Constants** at the top (after imports)
2. **Primary public type** (the main struct/enum the file defines)
3. **Supporting types** below the primary type
4. **API types** in order: Request, Response, Error
5. **`impl` blocks** after their type definitions
- Constructors first, then public methods, then private helpers
- Callers above callees — follow the call chain downward
## Test Layout
Within `#[cfg(test)] mod tests { ... }`:
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions or sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Constants provide small context up front. Test cases come next so engineers see *what* is
tested before *how* the helpers work.
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't wrap a single test in its own module. Keep nesting to two levels max. Shared
helpers go in the parent `mod tests` so sub-modules can `use super::*`.
## Building
- Most crates: `cargo check -p {crate_name}` or `cargo test -p {crate_name}`
- Crates using SQLx: `SQLX_OFFLINE=true cargo check -p {crate_name}`
- `storyteller-web` and `mysql_queries` require `SQLX_OFFLINE=true`
- `storyteller-web` has ~370 pre-existing warnings — check the last few lines for actual errors
## Key Crates
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -3,13 +3,14 @@ use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
/// Request body for the image cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct EstimateImageCostRequest {
/// The image model to estimate costs for.
pub model: CommonImageModel,
@@ -29,12 +30,15 @@ pub struct EstimateImageCostRequest {
/// Optional resolution.
pub resolution: Option<CommonResolution>,
// Optional quality.
pub quality: Option<CommonQuality>,
/// Number of images to generate in parallel.
pub image_batch_count: Option<u16>,
}
/// Describes the type of image generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
/// No input images — generate from prompt only.
@@ -94,6 +98,7 @@ mod tests {
generation_mode: GenerationMode::TextToImage,
aspect_ratio: None,
resolution: None,
quality: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
@@ -108,6 +113,7 @@ mod tests {
generation_mode: GenerationMode::ImageEdit { count: 2 },
aspect_ratio: None,
resolution: None,
quality: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const BEEBLE_SWITCHX_EDIT_VIDEO_PATH: &str = "/v1/generate/video/edit/beeble_switchx";
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct BeebleSwitchXEditVideoRequest {
pub uuid_idempotency_token: String,
pub source_video_media_token: Option<MediaFileToken>,
pub reference_image_media_token: Option<MediaFileToken>,
pub prompt: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BeebleSwitchXEditVideoResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video;
@@ -1,3 +1,4 @@
pub mod edit;
pub mod generate_kling_1_6_pro_image_to_video;
pub mod generate_kling_2_1_master_image_to_video;
pub mod generate_kling_2_1_pro_image_to_video;
@@ -7,7 +7,7 @@ use utoipa::ToSchema;
pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0";
/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based).
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
@@ -37,6 +37,9 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Output resolution quality tier (480p, 720p, 1080p).
pub output_resolution: Option<Seedance2p0OutputResolution>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
@@ -67,6 +70,17 @@ pub enum Seedance2p0BatchCount {
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0OutputResolution {
/// 480p
FourEightyP,
/// 720p (default)
SevenTwentyP,
/// 1080p
TenEightyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
@@ -1,16 +1,17 @@
use crate::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
use crate::common::responses::media_links::MediaLinks;
use chrono::{DateTime, Utc};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use serde::Deserialize;
use serde::Serialize;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::{IntoParams, ToSchema};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
@@ -53,6 +54,9 @@ pub struct ListSessionJobsItem {
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionRequestDetailsResponse {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
@@ -9,7 +9,7 @@ use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the video cost estimate and video generation endpoints.
#[derive(Serialize, Deserialize, ToSchema, Debug)]
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenVideoCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
@@ -6,5 +6,12 @@ use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenVideoGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
/// multiple order IDs) this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -56,6 +57,9 @@ pub struct OmniGenImageProviderModelDetails {
pub struct OmniGenImageModelDetails {
pub model: CommonImageModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -129,6 +133,7 @@ impl Default for OmniGenImageModelDetails {
fn default() -> Self {
Self {
model: CommonImageModel::NanoBananaPro,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -65,6 +66,9 @@ pub struct OmniGenVideoModelDetails {
pub model: CommonVideoModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
@@ -172,6 +176,7 @@ impl Default for OmniGenVideoModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_VIDEO_MODEL,
model_creator: None,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -0,0 +1,71 @@
use crate::prompts::get_prompt::GetPromptImageContextItem;
use chrono::{DateTime, Utc};
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
pub const BATCH_GET_PROMPTS_PATH: &str = "/v1/prompt/batch";
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct BatchGetPromptsQuery {
/// Prompt tokens to look up.
pub tokens: HashSet<String>,
}
#[derive(Serialize, ToSchema)]
pub struct BatchGetPromptsResponse {
pub success: bool,
pub prompts: Vec<BatchPromptInfo>,
}
#[derive(Serialize, ToSchema)]
pub struct BatchPromptInfo {
pub token: PromptToken,
/// The type of model used
pub maybe_model_type: Option<CommonModelType>,
/// The class of model used
pub maybe_model_class: Option<CommonModelClass>,
/// The service provider used
pub maybe_generation_provider: Option<GenerationProvider>,
/// Positive prompt (technically optional, but usually present)
pub maybe_positive_prompt: Option<String>,
/// Negative prompt (optional)
pub maybe_negative_prompt: Option<String>,
/// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.)
pub maybe_generation_mode: Option<CommonGenerationMode>,
/// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.)
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
/// OPTIONAL. Whether to generate audio.
pub maybe_generate_audio: Option<bool>,
/// OPTIONAL. The duration in seconds.
pub maybe_duration_seconds: Option<u32>,
/// Context images (optional)
pub maybe_context_images: Option<Vec<GetPromptImageContextItem>>,
pub created_at: DateTime<Utc>,
}
@@ -27,13 +27,6 @@ pub struct GetPromptSuccessResponse {
pub prompt: PromptInfo,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptImageContextItem {
pub media_token: MediaFileToken,
pub semantic: PromptContextSemanticType,
pub media_links: MediaLinks,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfo {
pub token: PromptToken,
@@ -130,6 +123,13 @@ pub struct PromptInfo {
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptImageContextItem {
pub media_token: MediaFileToken,
pub semantic: PromptContextSemanticType,
pub media_links: MediaLinks,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfoModeratorFields {
/// How many milliseconds it took to run generation.
@@ -1,2 +1,3 @@
pub mod batch_get_prompts;
pub mod create_prompt;
pub mod get_prompt;
@@ -0,0 +1,20 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse, BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
};
pub async fn beeble_switchx_edit_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: BeebleSwitchXEditVideoRequest,
) -> Result<BeebleSwitchXEditVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video;
@@ -1,3 +1,4 @@
pub mod edit;
pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_21_master_image_to_video;
pub mod generate_kling_21_pro_image_to_video;
@@ -0,0 +1 @@
pub mod omni_gen_image;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
pub const OMNI_GEN_IMAGE_GENERATE_PATH: &str = "/v1/omni_gen/generate/image";
pub async fn omni_gen_image_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenImageCostAndGenerateRequest,
) -> Result<OmniGenImageGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_IMAGE_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,2 @@
pub mod image;
pub mod video;
@@ -1,5 +1,6 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use reqwest::StatusCode;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
@@ -30,6 +31,12 @@ pub enum ApiError {
backend_hostname: Option<String>,
},
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
@@ -66,6 +73,8 @@ impl Display for ApiError {
ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg),
ApiError::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } =>
write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
@@ -0,0 +1,157 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::{info, warn};
use reqwest::Client;
use url::Url;
use tokens::tokens::media_files::MediaFileToken;
use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
pub struct DownloadMediaFileArgs<'a, P: AsRef<Path>> {
pub media_token: &'a MediaFileToken,
pub api_host: &'a ApiHost,
pub download_path: DownloadPath<P>,
}
pub enum DownloadPath<P: AsRef<Path>> {
/// Write the file to this exact path.
ExactFilename(P),
/// Write the file into this directory, generating a filename from the CDN URL extension.
Directory(P),
}
pub struct DownloadMediaFileResult {
/// The path the file was written to.
pub downloaded_file_path: PathBuf,
/// The full media file response from the API.
pub media_file_response: GetMediaFileSuccessResponse,
/// Size of downloaded file in bytes.
pub filesize_bytes: usize,
}
pub async fn download_media_file<P: AsRef<Path>>(
args: DownloadMediaFileArgs<'_, P>,
) -> Result<DownloadMediaFileResult, StorytellerError> {
let DownloadMediaFileArgs {
media_token,
api_host,
download_path
} = args;
// 1. Fetch media file info from the API.
let response = get_media_file(api_host, media_token).await?;
let media_class = &response.media_file.media_class;
let cdn_url = &response.media_file.media_links.cdn_url;
info!("Downloading media file {} of class {} from CDN: {}",
media_token.as_str(), media_class.to_str(), cdn_url);
// 2. Determine the output file path.
let output_path = match &download_path {
DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(),
DownloadPath::Directory(dir) => {
let filename = derive_filename_from_url(cdn_url, &media_token);
dir.as_ref().join(filename)
}
};
// 3. Download the bytes from the CDN.
let bytes = download_bytes(cdn_url).await?;
// 4. Write to disk.
let mut file = fs::File::create(&output_path)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.write_all(&bytes)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.flush()
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
Ok(DownloadMediaFileResult {
downloaded_file_path: output_path,
media_file_response: response,
filesize_bytes: bytes.len(),
})
}
// ── Helpers ──
async fn download_bytes(url: &Url) -> Result<Vec<u8>, StorytellerError> {
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
let response = client.get(url.as_str())
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?;
let status_code = response.status();
if !status_code.is_success() {
let body = response.text().await.unwrap_or_else(|err| {
warn!("Failed to retrieve response body: {}", err);
"".to_string()
});
return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}));
}
let bytes = response.bytes()
.await
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
Ok(bytes.to_vec())
}
/// Derive a filename from the CDN URL's path extension, falling back to the media token.
fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String {
let path = url.path();
let extension = Path::new(path)
.extension() // NB: without dot '.'
.and_then(|ext| ext.to_str())
.unwrap_or("bin");
format!("{}.{}", media_token.as_str(), extension)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_filename_from_url_extracts_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png");
}
#[test]
fn derive_filename_from_url_handles_no_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin");
}
#[test]
fn derive_filename_from_url_handles_query_params() {
let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4");
}
}
@@ -1 +1,2 @@
pub mod download_media_file;
pub mod upload_media_file_from_file;
@@ -1,7 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone, Debug)]
pub enum AudioListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
#[derive(Clone, Debug)]
pub enum AudioListRef {
MediaFileTokens(Vec<MediaFileToken>),
Urls(Vec<String>),
}
@@ -1,9 +1,9 @@
use tokens::tokens::characters::CharacterToken;
/// Forward-compatible character reference list.
#[derive(Copy, Clone, Debug)]
pub enum CharacterListRef<'a> {
CharacterTokens(&'a Vec<CharacterToken>),
#[derive(Clone, Debug)]
pub enum CharacterListRef {
CharacterTokens(Vec<CharacterToken>),
// In the future, we may have other identifiers for characters.
}
@@ -17,6 +17,8 @@ pub enum CommonImageModel {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -38,6 +38,12 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "sora_2")]
Sora2,
@@ -1,7 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone, Debug)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
#[derive(Clone, Debug)]
pub enum ImageListRef {
MediaFileTokens(Vec<MediaFileToken>),
Urls(Vec<String>),
}
@@ -1,7 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone, Debug)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
#[derive(Clone, Debug)]
pub enum ImageRef {
MediaFileToken(MediaFileToken),
Url(String),
}
@@ -1,7 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone, Debug)]
pub enum VideoListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
#[derive(Clone, Debug)]
pub enum VideoListRef {
MediaFileTokens(Vec<MediaFileToken>),
Urls(Vec<String>),
}
@@ -15,6 +15,9 @@ pub enum ArtcraftRouterError {
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// [Temporary during migration] The requested model/provider is not yet supported by the router.
UnsupportedProviderAndModelForNewApi(String),
/// Invalid or missing input arguments.
InvalidInput(String),
@@ -30,6 +33,7 @@ impl Display for ArtcraftRouterError {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::Download(e) => write!(f, "Download error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::UnsupportedProviderAndModelForNewApi(msg) => write!(f, "Unsupported provider/model (for new API during migration): {}", msg),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
}
@@ -1,5 +1,7 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
@@ -22,6 +24,9 @@ impl Display for ClientType {
#[derive(Debug)]
pub enum ClientError {
/// A RouterClient was required but not provided on the draft context.
RouterClientNotProvided,
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
@@ -43,6 +48,18 @@ pub enum ClientError {
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// The pre-dispatch context of media file token to URL map was not supplied.
MediaFileToUrlMapNotProvided,
/// A media file token was not found in the provided media-file-to-URL map.
MediaFileTokenNotFoundInMap { token: MediaFileToken },
/// The pre-dispatch context of character token to ID map was not supplied.
CharacterTokenToKinoviCharacterIdNotProvided,
/// A character token was not found in the provided character-token-to-id map
CharacterTokenNotFoundInMap { token: CharacterToken },
}
impl Error for ClientError {}
@@ -50,6 +67,9 @@ impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RouterClientNotProvided => {
write!(f, "A RouterClient is required but was not provided on the draft context")
}
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
@@ -71,6 +91,18 @@ impl Display for ClientError {
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::MediaFileToUrlMapNotProvided => {
write!(f, "Media file to URL map was not provided")
}
Self::MediaFileTokenNotFoundInMap { token } => {
write!(f, "Media file token '{}' was not found in the provided URL map", token.as_str())
}
Self::CharacterTokenToKinoviCharacterIdNotProvided => {
write!(f, "Character token to Kinovi character ID map was not provided")
}
Self::CharacterTokenNotFoundInMap { token } => {
write!(f, "Character token '{}' was not found in the provided character-token-to-id map", token.as_str())
}
}
}
}
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_dev::PlanArtcraftFlux1Dev;
pub(crate) fn estimate_image_cost_artcraft_flux_1_dev(
plan: &PlanArtcraftFlux1Dev<'_>,
plan: &PlanArtcraftFlux1Dev,
) -> ImageGenerationCostEstimate {
// Pricing: $0.02/image (2 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 2;
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_schnell::PlanArtcraftFlux1Schnell;
pub(crate) fn estimate_image_cost_artcraft_flux_1_schnell(
_plan: &PlanArtcraftFlux1Schnell<'_>,
_plan: &PlanArtcraftFlux1Schnell,
) -> ImageGenerationCostEstimate {
// Pricing: not available
ImageGenerationCostEstimate {
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles;
pub(crate) fn estimate_image_cost_artcraft_flux_2_lora_angles(
plan: &PlanArtcraftFlux2LoraAngles<'_>,
plan: &PlanArtcraftFlux2LoraAngles,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
@@ -43,7 +43,7 @@ mod tests {
model: CommonImageModel::Flux2LoraAngles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
resolution: None,
aspect_ratio: None,
quality: None,
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::PlanArtcraftFluxPro11;
pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1(
plan: &PlanArtcraftFluxPro11<'_>,
plan: &PlanArtcraftFluxPro11,
) -> ImageGenerationCostEstimate {
// Fal pricing: $0.04 per megapixel, billed by rounding up to the nearest
// megapixel. The default image_size values are ~1MP, so 4 cents per image.
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::PlanArtcraftFluxPro11Ultra;
pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1_ultra(
plan: &PlanArtcraftFluxPro11Ultra<'_>,
plan: &PlanArtcraftFluxPro11Ultra,
) -> ImageGenerationCostEstimate {
// Pricing: $0.06/image (6 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 6;
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
plan: &PlanArtcraftGptImage1,
) -> ImageGenerationCostEstimate {
// Pricing per the fal-ai GPT Image 1 docs, adapted for our artcraft billing:
//
@@ -62,7 +62,7 @@ mod tests {
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn base() -> GenerateImageRequest<'static> {
fn base() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
@@ -109,7 +109,7 @@ mod tests {
quality,
aspect_ratio,
image_batch_count: Some(batch),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base()
};
request.build().unwrap().estimate_costs()
@@ -7,7 +7,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
plan: &PlanArtcraftGptImage1p5,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
@@ -0,0 +1,210 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::{
ArtcraftGptImage2ImageSize, ArtcraftGptImage2Quality, PlanArtcraftGptImage2,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
) -> ImageGenerationCostEstimate {
// Same cost table as the Fal GPT Image 2 estimator.
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use ArtcraftGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(ArtcraftGptImage2Quality::Low, _) => 1,
// Medium quality
(ArtcraftGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Auto)) => 6,
// High quality
(ArtcraftGptImage2Quality::High, None | Some(S::Square)) => 22,
(ArtcraftGptImage2Quality::High, Some(S::SquareHd)) => 23,
(ArtcraftGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(ArtcraftGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(ArtcraftGptImage2Quality::High, Some(S::Auto)) => 23,
};
let cost_in_usd_cents = cost_per_image * plan.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,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::ArtcraftGptImage2NumImages;
fn plan(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> PlanArtcraftGptImage2 {
PlanArtcraftGptImage2 {
prompt: Some("p".to_string()),
image_inputs: None,
image_size,
quality,
num_images,
idempotency_token: "test".to_string(),
}
}
fn cents(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> u64 {
estimate_image_cost_artcraft_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, None, ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_artcraft_gpt_image_2(&plan(
ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
pub(crate) fn estimate_image_cost_artcraft_nano_banana(
plan: &PlanArtcraftNanaBanana<'_>,
plan: &PlanArtcraftNanaBanana,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
@@ -6,7 +6,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::PlanArtcraftNanaBanana2;
pub(crate) fn estimate_image_cost_artcraft_nano_banana_2(
plan: &PlanArtcraftNanaBanana2<'_>,
plan: &PlanArtcraftNanaBanana2,
) -> ImageGenerationCostEstimate {
// Pricing (USD cents per image), scaled off the 1K base cost of $0.08:
// 0.5K → $0.06 (0.75×)
@@ -6,7 +6,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
pub(crate) fn estimate_image_cost_artcraft_nano_banana_pro(
plan: &PlanArtcraftNanaBananaPro<'_>,
plan: &PlanArtcraftNanaBananaPro,
) -> ImageGenerationCostEstimate {
// Pricing: $0.15/image at 1K or 2K resolution, $0.30/image at 4K.
// (Stored as USD cents.)
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles;
pub(crate) fn estimate_image_cost_artcraft_qwen_edit_2511_angles(
plan: &PlanArtcraftQwenEdit2511Angles<'_>,
plan: &PlanArtcraftQwenEdit2511Angles,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
@@ -43,7 +43,7 @@ mod tests {
model: CommonImageModel::QwenEdit2511Angles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
resolution: None,
aspect_ratio: None,
quality: None,
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::PlanArtcraftSeedream4;
pub(crate) fn estimate_image_cost_artcraft_seedream_4(
plan: &PlanArtcraftSeedream4<'_>,
plan: &PlanArtcraftSeedream4,
) -> ImageGenerationCostEstimate {
// Pricing: $0.03/image (3 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 3;
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::PlanArtcraftSeedream4p5;
pub(crate) fn estimate_image_cost_artcraft_seedream_4p5(
plan: &PlanArtcraftSeedream4p5<'_>,
plan: &PlanArtcraftSeedream4p5,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
@@ -4,7 +4,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_5_lite::PlanArtcraftSeedream5Lite;
pub(crate) fn estimate_image_cost_artcraft_seedream_5_lite(
plan: &PlanArtcraftSeedream5Lite<'_>,
plan: &PlanArtcraftSeedream5Lite,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
@@ -4,6 +4,7 @@ pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_gpt_image_2;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
pub mod estimate_image_cost_artcraft_nano_banana_pro;
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev;
pub(crate) fn estimate_image_cost_fal_flux_1_dev(
plan: &PlanFalFlux1Dev<'_>,
plan: &PlanFalFlux1Dev,
) -> ImageGenerationCostEstimate {
// Pricing: $0.025 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.025/image = 3 cents rounded up.
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
pub(crate) fn estimate_image_cost_fal_flux_1_schnell(
plan: &PlanFalFlux1Schnell<'_>,
plan: &PlanFalFlux1Schnell,
) -> ImageGenerationCostEstimate {
// Pricing: $0.003 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.003/image = 1 cent rounded up.
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::PlanFalFluxPro11;
pub(crate) fn estimate_image_cost_fal_flux_pro_1p1(
plan: &PlanFalFluxPro11<'_>,
plan: &PlanFalFluxPro11,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04 per megapixel, billed by rounding up to nearest megapixel.
// Default image sizes are ~1MP (1024x1024), so ~$0.04/image = 4 cents.
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
pub(crate) fn estimate_image_cost_fal_flux_pro_1p1_ultra(
plan: &PlanFalFluxPro11Ultra<'_>,
plan: &PlanFalFluxPro11Ultra,
) -> ImageGenerationCostEstimate {
// Pricing: $0.06 per image.
let cost_per_image: u64 = 6;
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_imag
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
plan: &PlanFalGptImage1,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1/{text-to-image,edit-image}):
//
@@ -59,9 +59,9 @@ mod tests {
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> PlanFalGptImage1<'static> {
) -> PlanFalGptImage1 {
PlanFalGptImage1 {
prompt: Some("p"),
prompt: Some("p".to_string()),
image_urls: vec![],
image_size,
quality,
@@ -74,12 +74,12 @@ mod tests {
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> PlanFalGptImage1<'static> {
) -> PlanFalGptImage1 {
let image_urls = (0..num_input_images)
.map(|i| format!("https://fake.example.com/img{}.png", i))
.collect();
PlanFalGptImage1 {
prompt: Some("p"),
prompt: Some("p".to_string()),
image_urls,
image_size,
quality,
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_imag
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
plan: &PlanFalGptImage1p5<'_>,
plan: &PlanFalGptImage1p5,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
@@ -50,9 +50,9 @@ mod tests {
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> PlanFalGptImage1p5<'static> {
) -> PlanFalGptImage1p5 {
PlanFalGptImage1p5 {
prompt: Some("p"),
prompt: Some("p".to_string()),
image_urls: vec![],
image_size,
quality,
@@ -0,0 +1,209 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::{
FalGptImage2ImageSize, FalGptImage2Quality, PlanFalGptImage2,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_2(
plan: &PlanFalGptImage2,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-2 and fal-ai/gpt-image-2/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use FalGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage2Quality::Low, _) => 1,
// Medium quality
(FalGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(FalGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(FalGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(FalGptImage2Quality::Medium, Some(S::Auto)) => 6, // TODO: Not checked
// High quality
(FalGptImage2Quality::High, None | Some(S::Square)) => 22,
(FalGptImage2Quality::High, Some(S::SquareHd)) => 23,
(FalGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(FalGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(FalGptImage2Quality::High, Some(S::Auto)) => 23, // TODO: Not checked
};
let cost_in_usd_cents = cost_per_image * plan.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,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::FalGptImage2NumImages;
fn plan(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> PlanFalGptImage2 {
PlanFalGptImage2 {
prompt: Some("p".to_string()),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage2Quality::High, None, FalGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_2(&plan(
FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
pub(crate) fn estimate_image_cost_fal_nano_banana(
plan: &PlanFalNanoBanana<'_>,
plan: &PlanFalNanoBanana,
) -> ImageGenerationCostEstimate {
// Pricing: $0.039/image (no resolution multiplier — Gemini 25 Flash has no resolution option).
// Rounded to 4 cents per image.
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
pub(crate) fn estimate_image_cost_fal_nano_banana_2(
plan: &PlanFalNanaBanana2<'_>,
plan: &PlanFalNanaBanana2,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x.
// (Stored as USD cents.)
@@ -4,7 +4,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
pub(crate) fn estimate_image_cost_fal_nano_banana_pro(
plan: &PlanFalNanaBananaPro<'_>,
plan: &PlanFalNanaBananaPro,
) -> ImageGenerationCostEstimate {
// Pricing: $0.15/image at 1K or 2K resolution, $0.30/image at 4K.
// (Stored as USD cents.)
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
pub(crate) fn estimate_image_cost_fal_seedream_4(
plan: &PlanFalSeedream4<'_>,
plan: &PlanFalSeedream4,
) -> ImageGenerationCostEstimate {
// Pricing: $0.03 per image.
let cost_per_image: u64 = 3;
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
pub(crate) fn estimate_image_cost_fal_seedream_4p5(
plan: &PlanFalSeedream4p5<'_>,
plan: &PlanFalSeedream4p5,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04 per image.
let cost_per_image: u64 = 4;
@@ -2,7 +2,7 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::PlanFalSeedream5Lite;
pub(crate) fn estimate_image_cost_fal_seedream_5_lite(
plan: &PlanFalSeedream5Lite<'_>,
plan: &PlanFalSeedream5Lite,
) -> ImageGenerationCostEstimate {
// Pricing: $0.035 per image — round up to 4 cents.
let cost_per_image: u64 = 4;
@@ -4,6 +4,7 @@ pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_gpt_image_2;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
pub(crate) mod estimate_image_cost_fal_seedream_5_lite;
@@ -9,12 +9,12 @@ use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image:
use artcraft_client::endpoints::generate::image::text::generate_flux_1_dev_text_to_image::generate_flux_1_dev_text_to_image;
pub async fn execute_artcraft_flux_1_dev(
plan: &PlanArtcraftFlux1Dev<'_>,
plan: &PlanArtcraftFlux1Dev,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GenerateFlux1DevTextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
aspect_ratio: plan.aspect_ratio,
num_images: Some(plan.num_images),
};
@@ -49,7 +49,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
..base_flux_1_dev_image_request()
};
@@ -72,7 +72,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
..base_flux_1_dev_image_request()
};
@@ -98,7 +98,7 @@ mod tests {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
prompt: Some("a bear walking through an abandoned desert city at night".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_flux_1_dev_image_request()
};
@@ -9,12 +9,12 @@ use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_im
use artcraft_client::endpoints::generate::image::text::generate_flux_1_schnell_text_to_image::generate_flux_1_schnell_text_to_image;
pub async fn execute_artcraft_flux_1_schnell(
plan: &PlanArtcraftFlux1Schnell<'_>,
plan: &PlanArtcraftFlux1Schnell,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GenerateFlux1SchnellTextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
aspect_ratio: plan.aspect_ratio,
num_images: Some(plan.num_images),
};
@@ -48,7 +48,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a cyberpunk city at night"),
prompt: Some("a man and woman walking through a cyberpunk city at night".to_string()),
..base_flux_1_schnell_image_request()
};
@@ -71,7 +71,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
..base_flux_1_schnell_image_request()
};
@@ -97,7 +97,7 @@ mod tests {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a futuristic city at evening"),
prompt: Some("a man and woman walking through a futuristic city at evening".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_flux_1_schnell_image_request()
};
@@ -9,7 +9,7 @@ use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::Flu
use artcraft_client::endpoints::generate::image::angle::flux_2_lora_edit_image_angle::flux_2_lora_edit_image_angle;
pub async fn execute_artcraft_flux_2_lora_angles(
plan: &PlanArtcraftFlux2LoraAngles<'_>,
plan: &PlanArtcraftFlux2LoraAngles,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = Flux2LoraEditImageAngleRequest {
@@ -53,7 +53,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
horizontal_angle: Some(30.0),
vertical_angle: Some(0.0),
zoom: Some(1.0),
@@ -9,12 +9,12 @@ use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image
use artcraft_client::endpoints::generate::image::text::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
pub async fn execute_artcraft_flux_pro_1p1(
plan: &PlanArtcraftFluxPro11<'_>,
plan: &PlanArtcraftFluxPro11,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GenerateFluxPro11TextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
aspect_ratio: plan.aspect_ratio,
num_images: Some(plan.num_images),
};
@@ -49,7 +49,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
..base_flux_pro_1p1_image_request()
};
@@ -72,7 +72,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
..base_flux_pro_1p1_image_request()
};
@@ -98,7 +98,7 @@ mod tests {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
prompt: Some("a bear walking through an abandoned desert city at night".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_flux_pro_1p1_image_request()
};
@@ -9,12 +9,12 @@ use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to
use artcraft_client::endpoints::generate::image::text::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
pub async fn execute_artcraft_flux_pro_1p1_ultra(
plan: &PlanArtcraftFluxPro11Ultra<'_>,
plan: &PlanArtcraftFluxPro11Ultra,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GenerateFluxPro11UltraTextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
aspect_ratio: plan.aspect_ratio,
num_images: Some(plan.num_images),
};
@@ -49,7 +49,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
..base_flux_pro_1p1_ultra_image_request()
};
@@ -72,7 +72,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
..base_flux_pro_1p1_ultra_image_request()
};
@@ -98,7 +98,7 @@ mod tests {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a t-rex walking through an abandoned desert city at night"),
prompt: Some("a t-rex walking through an abandoned desert city at night".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_flux_pro_1p1_ultra_image_request()
};
@@ -14,14 +14,14 @@ use artcraft_client::endpoints::generate::image::text::generate_gpt_image_1_text
/// text-to-image, one for image-edit), so we dispatch on whether image refs
/// are present rather than calling a single multi-function endpoint.
pub async fn execute_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
plan: &PlanArtcraftGptImage1,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let inference_job_token = match plan.image_inputs {
let inference_job_token = match plan.image_inputs.clone() {
Some(image_inputs) => {
let request = GptImage1EditImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
image_media_tokens: Some(image_inputs.to_owned()),
image_size: plan.image_size.map(|s| s.to_edit()),
num_images: Some(plan.num_images.to_edit()),
@@ -39,7 +39,7 @@ pub async fn execute_artcraft_gpt_image_1(
None => {
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
prompt: plan.prompt.clone(),
image_size: plan.image_size.map(|s| s.to_t2i()),
num_images: Some(plan.num_images.to_t2i()),
image_quality: Some(plan.quality.to_t2i()),
@@ -89,7 +89,7 @@ mod tests {
fn build_edit_image_plan_smoke() {
let tokens = vec![MediaFileToken::new_from_str("mf_test00000000000000000000000000")];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
@@ -106,7 +106,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
prompt: Some("a horse walking through a cyberpunk city at night".to_string()),
..base_gpt_image_1_image_request()
};
@@ -129,8 +129,8 @@ mod tests {
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
prompt: Some("Change the background to a desert with cacti".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens.clone())),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
@@ -9,13 +9,13 @@ use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_func
use artcraft_client::endpoints::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen_image::gpt_image_1p5_multi_function_image_gen;
pub async fn execute_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
plan: &PlanArtcraftGptImage1p5,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GptImage1p5MultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
mask_image_token: None,
num_images: Some(plan.num_images),
image_size: plan.image_size,
@@ -56,7 +56,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
prompt: Some("a horse walking through a cyberpunk city at night".to_string()),
..base_gpt_image_1p5_image_request()
};
@@ -81,8 +81,8 @@ mod tests {
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
prompt: Some("Change the background to a desert with cacti".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens.clone())),
aspect_ratio: Some(CommonAspectRatio::Auto),
image_batch_count: Some(1),
..base_gpt_image_1p5_image_request()
@@ -109,7 +109,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
image_batch_count: Some(1),
prompt: Some("a bear walking through a abandoned desert city at night"),
prompt: Some("a bear walking through a abandoned desert city at night".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_gpt_image_1p5_image_request()
};
@@ -140,8 +140,8 @@ mod tests {
for ar in CommonAspectRatio::iter() {
println!("--- image-edit aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
prompt: Some("Change the background to an alien world"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
prompt: Some("Change the background to an alien world".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens.clone())),
aspect_ratio: Some(ar),
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -0,0 +1,145 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::PlanArtcraftGptImage2;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate;
use enums::common::generation::common_image_model::CommonImageModel;
/// Execute GPT Image 2 via the Artcraft omni-gen image endpoint.
pub async fn execute_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = OmniGenImageCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonImageModel::GptImage2),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
resolution: None,
aspect_ratio: plan.aspect_ratio_enum(),
quality: Some(plan.quality_enum()),
image_batch_count: Some(plan.num_images_u16()),
adjust_horizontal_angle: None,
adjust_vertical_angle: None,
adjust_zoom: None,
};
let response = omni_gen_image_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use artcraft_client::utils::api_host::ApiHost;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{get_artcraft_client, get_artcraft_client_for_env};
use tokens::tokens::media_files::MediaFileToken;
fn base_gpt_image_2_request() -> GenerateImageRequest {
GenerateImageRequest {
model: crate::api::common_image_model::CommonImageModel::GptImage2,
provider: crate::api::provider::Provider::Artcraft,
prompt: Some("a t-rex walking through a cyberpunk city at night".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_2() {
let client = get_artcraft_client_for_env(ApiHost::Localhost { port: 12345 });
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_2() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_2_high_quality() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: Some(CommonQuality::High),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -9,13 +9,13 @@ use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_functi
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_multi_function_image_gen_image::nano_banana_multi_function_image_gen;
pub async fn execute_artcraft_nano_banana(
plan: &PlanArtcraftNanaBanana<'_>,
plan: &PlanArtcraftNanaBanana,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = NanoBananaMultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
num_images: Some(plan.num_images),
aspect_ratio: plan.aspect_ratio,
};
@@ -52,7 +52,7 @@ mod tests {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
..base_nano_banana_image_request()
};
@@ -77,8 +77,8 @@ mod tests {
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
prompt: Some("Change the background to a sunny beach".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens.clone())),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
@@ -107,7 +107,7 @@ mod tests {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
prompt: Some("a shiba walking through a cyberpunk city at night".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()
};
@@ -138,8 +138,8 @@ mod tests {
for ar in CommonAspectRatio::iter() {
println!("--- image-edit aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
prompt: Some("Change the background to a sunny beach".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens.clone())),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
@@ -9,13 +9,13 @@ use artcraft_api_defs::generate::image::multi_function::nano_banana_2_multi_func
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_2_multi_function_image_gen_image::nano_banana_2_multi_function_image_gen;
pub async fn execute_artcraft_nano_banana_2(
plan: &PlanArtcraftNanaBanana2<'_>,
plan: &PlanArtcraftNanaBanana2,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = NanaBanana2MultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
num_images: Some(plan.num_images),
resolution: plan.resolution,
aspect_ratio: plan.aspect_ratio,
@@ -51,7 +51,7 @@ mod tests {
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
..base_nano_banana_2_image_request()
};

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