Compare commits

...
Author SHA1 Message Date
Tanish Manku b6c2b59876 Merge remote-tracking branch 'origin/main' into refactor/3d-componentize
# Conflicts:
#	frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx
#	frontend/apps/artcraft/tsconfig.tsbuildinfo
#	frontend/tsconfig.json
2026-05-09 14:09:37 -07:00
Tanish Manku 369c552096 PageDraw: Generate fixes and performance boost (#1494) 2026-05-09 13:51:25 -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
Tanish Manku d659f8485c ui-pagescene: recover shape geometry key on scene cache reload
Shapes survived the save side fine — `getChildren` copies the whole
`userData` (including `shapeKey`) into the proxy's `user_data` blob,
and `toJSON()` writes it out. The load side was the half that lost
the thread: `proxyScene.loadFromSceneJson`'s `Parim` branch was
calling `Scene.instantiate(json_object.object_name)`, but
`object_name` is the *display label* ("Cube", "Point Light"), not the
geometry key the switch matches on ("Box", "PointLight"). The switch
fell through to its else branch and returned `new THREE.Mesh(undefined,
material)` — an empty mesh with no geometry, which is why reloaded
shapes showed up in the outliner, were translate-gizmo-movable, but
rendered invisibly, weren't raycast-pickable, and ignored the scale
gizmo (nothing to scale).

Recover the geometry key from `user_data.shapeKey` (the same field
`addShape.ts:21` stashes and `actions/snapshots.ts:78-79` already
uses for undo/redo), falling back to `object_name` for older cached
scenes that pre-date the field. Re-stash `shapeKey` on the recreated
obj so subsequent save round-trips survive.

Drive-by: drop the leftover `console.log(scene_json)` that was
firing on every cache load.
2026-05-08 22:01:49 -07:00
Tanish Manku aec5e50f54 ui-pagescene: defer pointer-lock acquisition to mousemove so click-to-select works
`MouseControls.onMouseDown` was calling `lockControls.lock()` eagerly on
every left/middle mousedown. The browser would acquire pointer lock
between mousedown and mouseup of a clean click, which suppressed the
synthesized `click` event — so `useViewportPointer.onClick` never fired
and raycaster selection was dead. The pointer-lock "Press Esc to exit"
toast popped on every static click as a tell.

Drop the eager lock from `onMouseDown`. The existing
`handleMousePointerLock()` path (called from `onMouseMove`, line 547)
already requests lock when `isMouseClicked === true && !isLocked`, so a
real drag still acquires the lock on its first mousemove. A static
click now stays out of the lock state machine entirely and the click
event reaches `MouseControls.onMouseClick` for the raycast.

Pre-migration this code lived behind window-level click listeners
attached by SceneManager, where the lock-vs-click ordering didn't
matter. The lib migration moved click handling to canvas-level inside
`useViewportPointer.ts`, exposing the conflict.
2026-05-08 21:11:05 -07:00
Tanish Manku edd900d29b ui-pagescene: snapshot+restore camera quat across pointer-lock unlock to stop rotation snap
When the user left/middle-click drags to rotate the camera and releases,
Chromium synthesizes one final `mousemove` while exiting pointer lock so
it can reposition the cursor back to where the user grabbed it. At that
exact moment, Three's PointerLockControls still has `isLocked === true`
(the flag flips after the unlock event dispatches), so its mousemove
handler reads the synthesized `movementX/Y` as user input and rotates
the camera by it — producing a visible lurch back by roughly the inverse
of the user's last drag delta.

Capture the camera quaternion in `MouseControls.onMouseUp` immediately
before `lockControls.unlock()`. In a real (non-debug) `unlock` listener
attached at editor init, copy the snapshot back into the camera and call
`updateMatrixWorld()`. By the time `unlock` fires, the synthetic move
has already been applied, so overwriting is the surgical fix at the
boundaries we actually own — no Three.js subclassing, no per-frame
state machine.

Trace that confirmed the cause:
  [mc] up    button=1 locked=true quat=(-0.2684, 0.1394, 0.0393, 0.9524)
  [cam-frame]                     quat=(-0.2684, 0.1394, 0.0393, 0.9524)
  [plc] unlock                    quat=(-0.2180, 0.5112, 0.1358, 0.8202)
2026-05-08 21:02:05 -07:00
Tanish Manku 1ea94e4b42 ui-pagescene: hoist useViewportSize above early return in ControlPanelSceneObject
The hook was called below the `if (!currentSceneObject) return null`
guard, so renders where `currentSceneObject` was null skipped the hook
entirely. When it flipped to non-null on a later render, React saw a
new hook appear and threw "rendered more hooks than during the previous
render".

Move `useViewportSize()` (and the `getScale` helper that consumes it)
above the early return so the hook order is stable across renders.
2026-05-08 20:10:38 -07:00
Tanish Manku 32a7eb67dd artcraft: mount MainApp at root + scope TabBody pages with <div>
Two fixes for the regression that left the TopBar and tab switcher
invisible after the Stage3D migration:

1. index.tsx still rendered <PageScene /> directly under BrowserRouter,
   so the entire MainApp shell (TopBar, LoginModal, Toaster, modals,
   universal Tauri event listeners) was bypassed and the 3D editor
   appeared as the default and only view. Routes (_index.tsx,
   $sceneToken.tsx) had been updated to mount MainApp; the bootstrap
   entry was missed.

2. MainApp.TabBody returned each page component directly. Pages like
   PageDraw return a fragment whose top-level child is
   `position: fixed; inset: 0`; without a wrapping <div> those
   fixed-positioned children became direct siblings of the TopBar at
   the MainApp root, escaping their tab slot. Wrap every non-3D case
   in <div>. The 3D case stays unwrapped because Stage3DBody (lib)
   already returns a <div> outer wrapper.

Default boot view is now the IMAGE (TextToImage) page under a visible
TopBar; switching tabs keeps the TopBar fixed and pages render below.

Typecheck stays at 47.
2026-05-08 20:07:27 -07:00
bflatastic 5f70776afe website: unified credits chip for mobile and desktop view 2026-05-09 09:28:23 +07:00
Tanish Manku a883f79c6d ui-pagescene: Stage3DBody uses reactive useViewportSize hook
Was reading editor.adapter.getViewportSize() inline during render,
which captured pageWidth/pageHeight signal values at that single
moment without subscribing to changes. If the host's pageHeight
signal hadn't been updated by GlobalSettingsManager yet (initial
value is 0 in the artcraft signal), Stage3DBody would render with
viewport dimensions of 0 and never recover.

useViewportSize encapsulates the read + window resize listener and
re-renders the component when dimensions change.
2026-05-08 19:28:14 -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
Tanish Manku 42e1787576 ui-pagescene: lift PageEditor + PageScene shell into lib as <Stage3D />
Final step of the Stage3D migration. The lib now exposes a single
top-level component:

  <Stage3D adapter={adapter} sceneToken={...} cacheJsonString={...}
           onSceneSerialized={...} />

…that owns the EngineProvider, body composition (canvas, outliner,
control panel, prompt box, model selector, controls, etc.), and
the React-lifecycle-driven set3DPageMounted. Hosts only build their
own PageSceneAdapter and decide where to stash the cache string.

Lib additions:
- src/lib/Stage3D.tsx — top-level shell (provider + drag/loading
  layer + body)
- src/lib/Stage3DBody.tsx — ex-PageEditor; consumes adapter for
  uploadImage / uploadPlaneFromMediaToken / getViewportSize
- Public re-export of Stage3D + Stage3DProps

Apps changes:
- pages/PageScene/PageScene.tsx reduced to a 50-line wrapper that
  builds the Tauri adapter, owns the host-specific concerns
  (tab-cache via useTabStore, URL ↔ scene-token sync via react-
  router-dom, currentUserToken mirror), and renders <Stage3D />
- pages/PageScene/PageEditor.tsx — deleted (now Stage3DBody in lib)

Apps PageScene/ is now exactly three files, all genuinely host-
specific:
- PageScene.tsx (Tauri-flavored wrapper)
- useTauriPageSceneAdapter.tsx (Tauri-bound adapter implementation)
- index.tsx (exports PageScene)

The artcraft-website can now mount the same 3D editor by writing its
own ~50-line wrapper + a useWebPageSceneAdapter, with zero duplicated
editor body or controls.

Typecheck stays at 47, lib build clean.
2026-05-08 18:13:37 -07:00
Tanish Manku d3b0a0d78c ui-pagescene: migrate Controls3D into lib (transform mode + add-asset menu)
Controls3D moves into the lib alongside ControlsTopButtons. All host
coupling is now through the adapter:
- UploadModal3D → renderAssetUploader slot
- UploadModalImage → renderImageUploader slot
- UploadModalSplat → renderSplatUploader slot
- useTabStore.setActiveTab("IMAGE_TO_3D_OBJECT") → navigateToImageTo3D
- setLogoutStates() → performLogout

Lib additions:
- comps/Controls3D/Controls3D.tsx
- comps/Controls3D/index.tsx
- Public re-export from src/index.ts

Apps changes:
- pages/PageScene/PageEditor.tsx imports Controls3D from package
- pages/PageScene/comps/ removed entirely (was the last apps subtree
  inside PageScene/)

Typecheck stays at 47, lib build clean.
2026-05-08 18:10:43 -07:00
Tanish Manku 8556ef0a6b ui-pagescene: migrate ControlsTopButtons into lib (file menu + outliner toggle)
ControlsTopButtons + Help + LoadUserScenes + NewSceneFromTemplate +
ScenePicker + SceneCard now live in
@storyteller/ui-pagescene/comps/ControlsTopButtons/. They access host
plumbing through the adapter (showToast, listUserMediaFiles,
listFeaturedMediaFiles, getCdnUrl, onSceneTitleChange) and read scene
metadata reactively from the new sceneMeta + currentUserToken store
fields.

Lib additions:
- PageSceneStore.sceneMeta + currentUserToken with setSceneMeta /
  setCurrentUserToken actions; SceneMeta interface exported
- comps/ControlsTopButtons/{ControlsTopButtons,Help,LoadUserScenes,
  NewSceneFromTemplate,ScenePicker}/

Apps changes:
- ~/signals/scene.ts: signalScene now mirrors into PageSceneStore so
  the lib has reactive read access without depending on signals
- pages/PageScene/PageScene.tsx: lifted the URL ↔ scene-token sync
  useSignalEffect out of ControlsTopButtons (router-aware bookkeeping
  belongs at the apps shell), plus useSignalEffect mirroring
  authentication.userInfo into store.currentUserToken
- pages/PageScene/PageEditor.tsx: ControlsTopButtons import now
  resolves to @storyteller/ui-pagescene
- Deleted: pages/PageScene/comps/ControlsTopButtons/

Typecheck: 51 → 47 (deleted ControlsTopButtons had several pre-
existing typecheck issues that disappear in the rewrite). Lib build
clean.
2026-05-08 18:08:46 -07:00
Tanish Manku 6067849ef5 ui-pagescene: extend adapter for Stage3D migration (uploads/nav/logout/render-slots)
Six new adapter members covering the host coupling that Controls3D,
ControlsTopButtons, and PageEditor will need once they live in the
lib:

- renderImageUploader / renderSplatUploader — slot patterns mirroring
  the existing renderAssetUploader, for the two non-GLB upload modals
  Controls3D triggers
- uploadImage / uploadPlaneFromMediaToken — host-pipeline wrappers
  used by PromptBox3D and the gallery-drop handler. Signatures match
  @storyteller/common's UploadImageArgs so they pass through cleanly
- navigateToImageTo3D — the only cross-page navigation the lib needs
  today (Controls3D's "create 3D from image" button). Tauri host
  switches tabs; web host will navigate routes
- performLogout — backs the SettingsModal logout callback

useTauriPageSceneAdapter wires all six. No call sites yet — the
files that consume them are still in apps; the next commits move
them into the lib.

Typecheck stays at 51, lib build clean.
2026-05-08 18:01:33 -07:00
Tanish Manku 8836346150 ui-pagescene: migrate AssetMenu into lib (with renderAssetUploader slot)
The AssetMenu — asset browser modal, fetch hooks, mapping utilities,
demo data — moves out of the artcraft host into the lib alongside the
engine it serves. Host-platform calls (MediaFilesApi, BucketConfig,
addToast, UploadModal3D) are reached via the new adapter methods and
the renderAssetUploader slot.

Lib additions (frontend/libs/components/pagescene/src/lib):
- comps/AssetMenu/{AssetMenu,AssetModal}.tsx
- comps/AssetMenu/hooks/{useFeaturedObjects,useUserObjects}.tsx
- comps/AssetMenu/utilities/{fetchMediaItems,misc}.ts
- comps/AssetMenu/shared/{ItemElement,ItemElements}.tsx
- signals/demoAssets/* (5 files)

Hooks now read the adapter from EngineContext rather than importing
~/Classes/ApiManager directly. Fetcher functions take adapter as a
second argument.

Apps deletions:
- pages/PageScene/comps/AssetMenu/ (entire folder)
- pages/PageScene/signals/demoAssets/ (entire folder)
- pages/PageScene/signals/index.ts (now empty)

Apps updates:
- comps/Controls3D/Controls3D.tsx: AssetModal import now from
  @storyteller/ui-pagescene

Typecheck: 58 → 51 (the deleted AssetModal had several pre-existing
typecheck issues that disappear with the rewrite).
nx build ui-pagescene clean.

Apps PageScene/ now contains only host-coupled comps Controls3D and
ControlsTopButtons, plus the route shell (PageScene.tsx, PageEditor.tsx,
useTauriPageSceneAdapter.tsx). Everything else is in the lib.
2026-05-08 17:30:30 -07:00
Tanish Manku 86be8461ec ui-pagescene: add renderAssetUploader slot (host-rendered UploadModal3D)
The lib's AssetMenu (next commit) needs a way to render the host's
UploadModal3D from inside its own modal. Following the same slot
pattern as renderAssetBrowser/renderSceneLoader, a new
renderAssetUploader slot lets the host plug in its file picker +
validation + splat conversion UI without the lib pulling in any
host-component imports.

useTauriPageSceneAdapter wires UploadModal3D into the slot. File
renamed to .tsx since the slot returns JSX.

Typecheck stays at 58.
2026-05-08 17:22:54 -07:00
Tanish Manku 3e7c67abce ui-pagescene: extend adapter with media-listing/cdn-url/toast slots
Adds the surface AssetMenu needs to live in the lib without importing
artcraft-host APIs:

- listUserMediaFiles / listFeaturedMediaFiles — paginated media
  listings backed by MediaFilesApi
- getCdnUrl — wraps host BucketConfig for thumbnail composition
- showToast — wraps host addToast / react-hot-toast pipeline

useTauriPageSceneAdapter wires all four to the existing artcraft host
plumbing (~/Classes/ApiManager/MediaFilesApi, ~/api/BucketConfig,
~/signals/addToast). No call sites yet — the AssetMenu file move that
consumes these is the next step.

Typecheck stays at 58.
2026-05-08 17:19:26 -07:00
Tanish Manku 05097945f6 ui-pagescene: move FilterEngineCategories/FilterMediaType/FilterMediaClasses/ToastTypes into lib
These four enums are the asset-browsing vocabulary the AssetMenu and
related host code share with the engine. Moving them into the lib
gives a single canonical identity, lets the lib own the AssetMenu
internals end-to-end, and aligns with the same pattern used for
AssetType / Visibility / etc.

Apps `~/enums/QueryFilters.ts` and `~/enums/toasts.ts` become thin
re-exports from @storyteller/ui-pagescene; existing host imports are
unchanged.

This is a prep step for the AssetMenu migration — Chunk 1 in the
ongoing PageScene componentization. No functional changes; typecheck
stays at 58.
2026-05-08 17:17:39 -07:00
Tanish Manku f8cd171b34 artcraft: lift tab routing to MainApp; PageScene becomes pure 3D editor
The 12-tab conditional in TabbedPages was a duplicate of the TopBar's
MenuIconSelector — both were independently switching on
useTabStore.activeTabId. Lifting it out gives PageScene a single
responsibility (the 3D editor) and lets the React mount/unmount of
PageScene drive the engine lifecycle directly, without imperative
set3DPageMounted flag flips from TopBar / appMenu.

Architecture:
  Route → MainApp                     (always mounted)
            ├ TopBar                  (universal chrome)
            ├ universal modals/Toaster
            ├ Tauri event listeners   (always-on)
            └ TabBody (switch on activeTabId)
                ├ PageScene           (only when "3D")
                │   └ EngineProvider  (mounts/unmounts the engine)
                ├ PageDraw            (only when "2D")
                └ … 11 other branches

PageScene mount → useEffect sets is3DPageMounted=true; cleanup sets
false. Engine's remountEngine() gate uses the same flag, so the
behavior is preserved without TopBar/appMenu/ImageTo3DExperience
needing to know about the engine.

Deletions:
- pages/PageScene/TabbedPages.tsx — folded into MainApp's TabBody
- pages/PageScene/engineLifecycle.ts — only set3DPageMounted was
  still in use, and that's now driven by PageScene's mount effect
- pages/PageScene/GlobalSettingsManager.tsx — dead since the real
  GlobalSettingsManager lives inline in app/src/index.tsx

PageEditor.tsx no longer renders TopBar or LoginModal (moved to
MainApp). It also drops the activeTabId === "3D" wrap and the 2D
drag handler; PageDrawWithGalleryDrop in MainApp handles the 2D
drop dispatch into PageDraw's existing window-event listener.

Routes (_index.tsx, $sceneToken.tsx) now mount <MainApp /> instead
of <PageScene />.

Typecheck: 60 → 58 (two pre-existing unused-import warnings removed
along with the deleted files).
2026-05-08 17:14:48 -07:00
Brandon Thomas ffde0b06c0 beeble webhook (#1489) 2026-05-08 20:06:09 -04:00
Tanish Manku 3ff43fcf99 ui-pagescene: load ammo.wasm.js once via singleton; drop dead created-one-element div
Editor.constructor was injecting <script src="jsm/libs/ammo.wasm.js"> into
document.body and creating <div id="created-one-element"> on every
construction. Strict mode + provider remounts produced duplicate tags;
the div was unreferenced anywhere in the codebase.

Replaced with a module-level Promise singleton (ammoLoader.ts) that
appends the script exactly once. Existing tags (e.g. from a prior
session) are detected and reused. The div is gone.

This is a correctness cleanup, not a behavior change for first-mount —
the script still loads on first Editor construction, just no longer
duplicates on subsequent ones.
2026-05-08 16:58:28 -07:00
Tanish Manku ebb0341347 artcraft: drop apps PageScene shim layer; consumers point at ui-pagescene directly
Now that PageEditor, PageScene, host comps (Controls3D, ControlsTopButtons,
AssetMenu, ItemElement), TopBar, ErrorDialog, ApiManager classes,
backgroundLoadedMedia, and ImageTo3DExperience all import their store,
enums, models, contexts, hooks, actions, drag-and-drop, and comps
directly from @storyteller/ui-pagescene, the apps shim files have no
remaining consumers.

Deleted shims:
- pages/PageScene/{PageSceneStore,sceneMetadata}.ts
- pages/PageScene/{enums,models}/index.ts
- pages/PageScene/actions/* (12 files)
- pages/PageScene/hooks/* (4 files)
- pages/PageScene/contexts/EngineContext/* (3 files)
- pages/PageScene/DragAndDrop/DndAsset.ts
- pages/PageScene/comps/{DragComponent,PrecisionSelector,FocalLengthDisplay,
  AspectRatioMenu,Outliner,ControlPanelSceneObject,SceneContainer,
  PreviewEngineCamera}/ (and their index/impl files)
- pages/PageScene/comps/{EngineCanvases,PoseModeSelector,EditorLoadingBar,
  PreviewImages,OnboardingHelper}.tsx

Also deleted orphan files left over from earlier shimming that no caller
referenced anymore: SceneContainer/{Letterbox.tsx,helpers.ts},
ControlPanelSceneObject/{DraggablePrecisionMutator.tsx,utils/*}.

Apps PageScene now contains only host-only files: PageScene.tsx,
PageEditor.tsx, TabbedPages.tsx, useTauriPageSceneAdapter.ts,
GlobalSettingsManager.tsx, engineLifecycle.ts, signals/demoAssets/,
plus the still-host-coupled comps AssetMenu, Controls3D,
ControlsTopButtons.

Typecheck stays at the 60-error pre-existing baseline.
nx build ui-pagescene clean.
2026-05-08 16:29:49 -07:00
Tanish Manku ca4baf243c artcraft: bulk-rewrite shim imports to package imports + alias KeymapKeyGroup
Lifts the remaining apps/PageScene shim-paths inside the host's own
files to direct package imports. The shim files themselves stay
(actions/, hooks/, contexts/, DragAndDrop/, sceneMetadata.ts,
comps/{DragComponent,PrecisionSelector,FocalLengthDisplay,
AspectRatioMenu,PoseModeSelector,EngineCanvases,Outliner,
ControlPanelSceneObject,SceneContainer,PreviewEngineCamera,
PreviewImages,OnboardingHelper,EditorLoadingBar,
ControlsTopButtons/Help/Help}); they're still load-bearing for the
host-coupled comps that haven't migrated yet (AssetMenu/,
ControlsTopButtons/, Controls3D/) and they'll get cleaned up when
those move.

Updated host files to import directly from @storyteller/ui-pagescene:
- PageEditor.tsx (8 imports collapsed: hooks/*, EngineContext,
  actions, GridVisibleChangedEvent + pickDropPosition)
- PageScene.tsx (EngineProvider)
- comps/Controls3D/Controls3D.tsx (EngineContext, setTransformMode)
- comps/ControlsTopButtons/ControlsTopButtons.tsx (sceneMetadata)
- comps/AssetMenu/shared/ItemElement.tsx (DndAsset → dragAndDrop
  named import, EngineContext)

Lib public API:
- KeyGroup (the binding-category string-union type from keymap)
  re-exported as `KeymapKeyGroup` to avoid name collision with the
  Help component kit's KeyGroup (the keyboard-icon visual primitive).

Verified: artcraft typecheck at 61 errors (down from 62 — one fewer
pre-existing unused-import warning).
2026-05-08 16:16:49 -07:00
Tanish Manku 60e944d22b artcraft: delete apps PageScene/engine shim folder
The engine/* shims (26 one-line re-export files) had no remaining
internal callers — every apps/PageScene/* file that previously
reached into engine/X has been migrated to either a sibling shim
(actions/, hooks/, contexts/, etc., which forward to the lib) or
directly to the @storyteller/ui-pagescene package.

PageEditor.tsx had the only two lingering engine-shim imports;
both fold into a single import line from the package:

  import {
    GridVisibleChangedEvent,
    pickDropPosition,
  } from \"@storyteller/ui-pagescene\";

The whole apps/PageScene/engine/ tree (editor.ts, scene.ts,
save_manager.ts, helper.ts, scene_manager_api.ts, keybinds_controls.ts,
keymap.ts, EngineStoreBridge.ts, events/{EngineEvent,EngineEventBus},
editor/{HistoryManager, ViewportController, PostProcessingPipeline,
GizmoController, CameraController, SelectionBridge}.ts,
editor/actions/{Color,Create,Delete,Lock,Transform,Visibility}Action.ts,
editor/actions/snapshots.ts, cameraMath.ts, pickDropPosition.ts,
pointer.ts) is now gone — 26 files deleted.

Verified: artcraft typecheck still at 62 errors.
2026-05-08 16:13:20 -07:00
Tanish Manku 41cc84d99c artcraft: collapse apps PageScene/{models,enums,datastructures} into shims
The lib already owns the canonical sources for these. Apps copies are
now redundant code that can drift; collapsing to one-line re-exports
removes that risk.

- apps/.../PageScene/models/ — was 7 .ts/.tsx files; now a single
  index.ts that re-exports the full type surface from
  @storyteller/ui-pagescene. Other artcraft modules (MediaFilesApi,
  VoiceDesignerApi, WeightsApi, UserBookmarksApi, BetaKeysApi,
  PromptsApi, ImageTo3DExperience, backgroundLoadedMedia) keep their
  `~/pages/PageScene/models` import path unchanged.
- apps/.../PageScene/enums/ — was 10 enum files; now a single index.ts
  re-exporting CameraAspectRatio, EditorStates, MediaFileType +
  MediaFileSubtype, MediaFileClass, WeightCategory, WeightType,
  Visibility, FetchStatus, GenerateTtsAudioErrorType, JobState,
  ArtStyle from the lib.
- apps/.../PageScene/datastructures/ — was a single common.ts (XYZ +
  Simple3DVector) with no remaining importers. Deleted; lib's copy
  is the single source. (XYZ + Simple3DVector are exported from
  @storyteller/ui-pagescene for direct host imports if needed.)

Lib's index.ts adds MediaFileType + MediaFileSubtype + MediaFileClass
+ WeightCategory + WeightType + Visibility + FetchStatus +
GenerateTtsAudioErrorType + JobState + ArtStyle to its enums block.

Verified: artcraft typecheck at 62 errors (one fewer pre-existing
warning -- a stale unused-export now resolved through the shim).
2026-05-08 16:10:50 -07:00
Tanish Manku 7dda05e980 artcraft: surface remaining scene model types in ui-pagescene public API
Adds MediaInfo, Pagination, Prompts, UserDetailsLight, MediaFile,
GetMediaListResponse, GetMediaFileResponse,
VoiceConversionModelListItem/Response, CreatorDetails, and the three
UserBookmark types to the lib's public surface so host modules that
previously imported from apps/.../PageScene/models can switch
imports to @storyteller/ui-pagescene without losing types. The lib
already had all of these internally; this commit just promotes
them through the public index.

Verified: artcraft typecheck holds at 63 errors.
2026-05-08 16:06:18 -07:00
Tanish Manku 7f502e0cc2 artcraft: lift 12-tab switch out of PageEditor into TabbedPages.tsx
PageEditor.tsx had two responsibilities mashed together: render the
3D editor body when tab===3D, and route the user to one of 11 other
non-3D pages otherwise. The non-3D routing is unrelated to PageScene
internals and was dragging in PageDraw, TextToImage, ImageToVideo,
VideoFrameExtractor, VideoWatermarkRemover, ImageWatermarkRemover,
ImageTo3DObject, ImageTo3DWorld, RemoveBackground, Angles,
Storyboard, AppsIndexPage as transitive imports.

Moved:
- New apps/.../PageScene/TabbedPages.tsx — renders the 12 alternate-
  tab branches. Self-contained: useTabStore + useStoryboardPageEnabled
  + the alternate-page imports.
- PageEditor.tsx loses 11 page imports + the storyboard-page hook +
  the 12-tab JSX block (60 lines). Renders <TabbedPages /> as the
  single replacement.

Behavior is unchanged — TabbedPages still mounts as a sibling of the
3D content inside PageEditor, so the EngineProvider lifecycle still
covers all tabs (engine remounts when the user comes back to 3D).
The full route-level lift-out (where non-3D tabs don't even mount
EngineProvider) is the next step; this is the prerequisite refactor.

Verified: artcraft typecheck still at 63 errors (all pre-existing).
2026-05-08 15:48:07 -07:00
Tanish Manku 3b839d50a0 artcraft: move EditorLoadingBar into ui-pagescene (driven by store.editorLoader)
The bar previously read host-level loadingBarData/loadingBarIsShowing
signals — orphaned writes after Stage A; the engine no longer drives
them. The lib version reads from store.editorLoader (which IS
populated by EditorLoaderEvent on the bus from save_manager's
save/load paths) so the bar actually shows during the right
operations.

- src/lib/comps/EditorLoadingBar.tsx: reads editorLoader.{isShowing,
  message} from PageSceneStore, supplies a stable label and a fixed
  100% progress (the bar is binary-shown for engine save/load; the
  prior progress field was never animated).
- Apps copy becomes a one-line re-export shim.

artcraft/app/src/signals/loadingBar.ts is now orphaned (no readers,
no writers anywhere). Left in place — harmless, can be deleted in a
drive-by cleanup.

Verified: artcraft typecheck at 63 errors (down from 70 — the
unused-import errors that referenced loadingBarData/IsShowing
disappeared with the migration).
2026-05-08 15:32:34 -07:00
Tanish Manku 6fffb3a734 artcraft: nx sync — pagescene lib references + artcraft tsconfig refs
Output of `nx sync` after the comp migrations:
- pagescene/tsconfig.lib.json picks up the new transitive lib refs
  it inherited from the migrated comps (button-icon, tooltip,
  close-button, label, button, input, button-dropdown).
- apps/artcraft/tsconfig.json gets pagescene listed before pagedraw
  in references (alphabetical normalization by Nx).

`npx nx build @storyteller/ui-pagescene` succeeds end-to-end:
  dist/index.js: 1,228.43 kB (gzip: 319.60 kB)
  dist/index.d.ts emitted with the full public API surface.

Artcraft typecheck still at 70 errors (all pre-existing).
2026-05-08 15:14:48 -07:00
Tanish Manku e01b02e463 artcraft: move OnboardingHelper + Help icon kit into ui-pagescene
OnboardingHelper had been left in apps because it depended on the
Help.tsx icon kit (Key, KeyGroup, Mouse, Plus etc.) under
ControlsTopButtons/Help. Help.tsx itself has no host coupling --
it imports only @storyteller/ui-label -- so both move into the lib
together:

- src/lib/comps/ControlsTopButtons/Help/{Help,index}.tsx — keyboard
  + mouse icon visual primitives (Key, KeyGroup, Mouse, Plus,
  ShortcutsGroup, Shortcut, Help itself).
- src/lib/comps/OnboardingHelper.tsx — the WASD/click hint that
  fades out on first key/mouse interaction.

Apps copies become re-export shims. Public API exposes the icon
primitives so future host-side help overlays can reuse them.

Verified: artcraft typecheck at 70 errors (one fewer than the 71
prior; one of OnboardingHelper's pre-existing unused-import errors
disappeared with the migration).
2026-05-08 15:07:57 -07:00
Tanish Manku e2f1b218bc artcraft: move viewport-dependent comps into ui-pagescene
Migrates the comps that previously read pageHeight/pageWidth from
the artcraft `~/signals` surface; they now use a new useViewportSize
hook that reads through adapter.getViewportSize when supplied (host
threads its existing pageWidth/pageHeight signals through), falling
back to window.innerWidth/innerHeight + a resize listener.

Comps moved:
- Outliner — uses viewport.height for the editor-height calculation
  and viewport.width for the dual-layout breakpoint at 2000px.
- ControlPanelSceneObject — viewport.height for the small-screen
  scale calc.
- SceneContainer — viewport for the container dimensions, with the
  56px TopBar deduction extracted to a TOP_BAR_PX constant.
- PreviewEngineCamera — viewport.width / viewport.height for
  responsive UI thresholds (also drops the explicit useSignals call).
- PreviewImages — viewport-derived image-grid dimensions.

New: src/lib/hooks/useViewportSize.ts. Reactive, adapter-aware, with
a window.innerWidth/innerHeight fallback for hosts that don't supply
getViewportSize.

Apps copies become one-line `export { X } from "@storyteller/ui-
pagescene"` re-export shims.

Lib public API exposes:
- The 5 newly-migrated comps.
- useViewportSize hook (useful to host code that wants the same
  reactivity contract).

Comps still in apps (host-coupled, blocked behind UploadModal3D /
MediaFilesApi / signalScene / addToast / loadingBarData):
- AssetMenu/, ControlsTopButtons/, Controls3D/, EditorLoadingBar,
  OnboardingHelper. These either become adapter render-slots
  (renderAssetBrowser is already on the interface) or get migrated
  once their host deps are surfaced through the adapter.

Verified: artcraft typecheck at 71 errors (one fewer than the prior
72 baseline; the dropped error was a pre-existing implicit-any in
the migrated PreviewEngineCamera that disappeared with the rewrite).
2026-05-08 15:06:07 -07:00
Tanish Manku 2482413a4a artcraft: move pure-3D comps into ui-pagescene (DragComponent + 5 more)
Migrates the comps with no host-platform coupling beyond the lib's
own store/adapter:

- DragComponent — drag-position visual driven by store.dragItem
- PrecisionSelector — precision-input popover (store-driven)
- FocalLengthDisplay — focal-length readout (store-driven)
- AspectRatioMenu — aspect-ratio picker (calls setCameraAspect)
- PoseModeSelector — FK pose mode toggle (store + getActiveEditor)
- EngineCanvases (EditorCanvas + CameraViewCanvas) — the canvas
  refs that drive store.editorCanvasEl / camViewCanvasEl

All ~/-aliased imports rewritten to lib-relative paths. Apps copies
become one-line `export { X } from "@storyteller/ui-pagescene"` shims.

Comps left in apps (need adapter expansion or significant re-plumbing):
- AssetMenu/ — UploadModal3D + MediaFilesApi + addToast + BucketConfig
- ControlsTopButtons/ — UploadModal3D + signalScene + scene/auth signals
- Controls3D/ — UploadModalSplat + useTabStore + setLogoutStates
- EditorLoadingBar — host loadingBarData/IsShowing signals
- Outliner — host pageHeight/Width signals (could swap to
  adapter.getViewportSize but lazy-deferred)
- ControlPanelSceneObject — host pageHeight signal + custom utils
- SceneContainer — host pageHeight/Width signals
- PreviewImages — host pageHeight/Width signals
- PreviewEngineCamera — host pageHeight/Width signals + Letterbox
- OnboardingHelper — depends on ControlsTopButtons/Help (chain
  blocked by ControlsTopButtons)

These either become adapter render-slots (renderAssetBrowser already
exists for AssetMenu) or get migrated once their host dependencies
are surfaced through the adapter.

Verified: artcraft typecheck still at 72 errors (all pre-existing).
2026-05-08 14:49:00 -07:00
Tanish Manku 8250aa0a05 artcraft: move actions/ + hooks/ + DragAndDrop/ + contexts/ into ui-pagescene
Lib gains the React/orchestration layer that sits between the engine
and the host React tree:

- src/lib/actions/* (12 dispatchers + index): addCharacter, addObject,
  addShape, deleteObject, selectObject, setCameraAspect,
  setObjectColor (+ ColorSession), setTransformMode, toggleObjectLock,
  toggleObjectVisibility, transformObject (+ TransformSession),
  deselectObject, beginColorSession, beginTransformSession.
- src/lib/hooks/*: useEditorCanvas, useCamViewCanvas, useFreeCam,
  useViewportPointer, useViewportKeyboard. ~/-aliased imports
  rewritten to relative paths inside the lib.
- src/lib/contexts/EngineContext/{EngineContext,EngineProvider}: the
  React context boundary. EngineProvider takes adapter + cache hooks
  via props and constructs `new Editor(adapter)`.
- src/lib/DragAndDrop/DndAsset.ts: the asset-drop singleton, now
  reading viewport size through `editor.adapter.getViewportSize?.()`
  with a `window.innerWidth/innerHeight` fallback when the host
  doesn't supply one.
- src/lib/sceneMetadata.ts: the SceneGenereationMetaData helper used
  by the save flow + the EngineProvider cache snapshot.

Adapter interface gains:
- getViewportSize?(): { width: number; height: number }
  Used by DndAsset for drop hit-testing. Optional — if the host
  omits it, the lib falls back to window.

Apps wrappers:
- All apps copies become one-line `export { X } from "@storyteller/
  ui-pagescene"` re-export shims so existing importers (PageEditor,
  AssetMenu items, ImageTo3DExperience, hooks, EngineContext
  consumers) keep their import paths unchanged.
- useTauriPageSceneAdapter.ts now wires getViewportSize from the
  existing pageWidth/pageHeight signals so the artcraft host's
  TopBar height + page chrome are honored.

Public API (src/index.ts) adds:
- All 12 action dispatchers + ColorSession/TransformSession types.
- The 4 hooks (useEditorCanvas, useCamViewCanvas, useFreeCam,
  useViewportPointer, useViewportKeyboard).
- EngineContext + setActiveEditor/getActiveEditor + EditorExpandedI.
- EngineProvider.
- dragAndDrop singleton.
- getSceneGenerationMetaData.

Verified: artcraft typecheck holds at 72 errors (all pre-existing,
mostly in comps/ and unrelated areas like SoundManager). Lib's own
typecheck cycle through `noEmitOnError: false` produces a clean
dist/index.d.ts with the new public surface.

Remaining for Stage B: comps/ (15 React UI components), lifting the
12-tab switch out of PageEditor.tsx, nx build smoke + manual end-
to-end test.
2026-05-08 14:38:55 -07:00
Tanish Manku 3d646b8f19 artcraft: integrate ui-pagescene -- engine files become re-export shims
Final integration step. The lib's @storyteller/ui-pagescene is now the
canonical source for PageScene's engine + state; the artcraft host
constructs a Tauri-backed PageSceneAdapter and threads it through
EngineProvider into Editor.

Changes:

apps/PageScene/engine/* -- previously held duplicate copies; now:
- Files imported by other artcraft code (editor.ts, scene.ts,
  helper.ts, save_manager.ts, scene_manager_api.ts,
  keybinds_controls.ts, keymap.ts, pickDropPosition.ts, pointer.ts,
  cameraMath.ts, editor/{HistoryManager, ViewportController,
  PostProcessingPipeline, GizmoController, CameraController,
  SelectionBridge}.ts, editor/actions/*.ts) become tiny
  `export * from "@storyteller/ui-pagescene"` re-export shims so the
  existing 50+ importers (PageEditor, EngineProvider, EngineContext,
  hooks/, DragAndDrop/, action dispatchers) keep working unchanged.
- Files only used internally by the now-shimmed engine code
  (CustomOutlinePass.js, FindSurfaces.js, TransformControls.js,
  chromakey.js, shaders.js, InfiniteGridHelper.ts, browserChecks.ts,
  placeholder_manager.ts, KinHelpers/*, EditorState/*,
  APIManagerResponseSuccess.ts, proxy/*) are deleted -- the lib
  carries the canonical copies.
- engine/api_manager.ts + engine/api_fetchers.ts deleted; their
  bodies move to the new useTauriPageSceneAdapter.ts hook (see below).

EngineProvider:
- Takes a new PageSceneAdapter prop and constructs `new Editor(adapter)`.
- Reads adapter via ref so adapter identity changes don't tear down
  the live engine.

apps/PageScene/PageScene.tsx:
- Uses useTauriPageSceneAdapter() to build the adapter, threads it
  into <EngineProvider adapter={adapter} ...>.

apps/PageScene/useTauriPageSceneAdapter.ts (new):
- Single source of all the host-side platform plumbing. Wires
  enqueueGeneration -> GenerateImage (tauri-api), saveScene/loadScene
  to the api_manager+api_fetchers bodies inlined here, fetchAsset to
  FetchProxy with LoadWithoutCors fallback, getCdnOrigin/
  getApiSchemeAndHost to artcraft singletons, getMediaUrlByToken to
  MediaFilesApi.GetMediaFileByToken, onSceneTitleChange to
  signalScene, getCurrentUserToken to the authentication signal.

Lib public API (src/index.ts):
- Adds Editor (default), Scene, SceneUtils, SaveManager, SceneManager,
  MouseControls, buildKeymap/dispatchBinding, all editor subsystems
  (HistoryManager, ViewportController, PostProcessingPipeline,
  GizmoController, CameraController, SelectionBridge), all action
  classes (ColorAction, CreateAction, DeleteAction, LockAction,
  TransformAction, VisibilityAction), engine utilities
  (pickDropPosition, cameraMath surface, pointer helpers,
  isPointerLockSupported), and the SceneGenereationMetaData type.

Adapter interface (lib/adapter.ts):
- Adds getMediaUrlByToken(token): Promise<string> to replace the
  scene's prior MediaFilesApi field.

Verified: artcraft typecheck at 72 errors (down from 124 baseline --
50+ pre-existing ApiManager-related errors disappeared along with
the deleted apps engine internals). All remaining errors are
pre-existing in unrelated AssetMenu/LoadUserScenes/PageEditor modal
config / SoundManager soundboard / pre-existing engine type casts.
2026-05-08 14:26:31 -07:00
Tanish Manku 9ee43d4e4a artcraft: land remaining engine + EditorState + actions in ui-pagescene
Bulk-copies the rest of the engine cluster into the lib with the
adapter wiring that makes editor.ts platform-agnostic:

Lib gains:
- engine/{scene, save_manager, helper, scene_manager_api,
  keybinds_controls, keymap, editor}.ts
- engine/editor/SelectionBridge.ts
- engine/editor/actions/{Color, Create, Delete, Lock, Transform,
  Visibility}Action.ts + snapshots.ts
- engine/EditorState/* (full JSON serialization schema)

Editor constructor signature change (lib-side only):
- Now takes a PageSceneAdapter parameter and exposes it as
  `editor.adapter` for subsystems that need platform-supplied
  capabilities (scene's fetchAsset/getMediaUrlByToken,
  save_manager's saveSceneState/loadSceneState).
- Drops `~/signals` imports: signalScene calls now route through
  adapter.onSceneTitleChange, authentication.userInfo via
  adapter.getCurrentUserToken, and the loadingBarData/IsShowing
  signals are dropped (the same UX is driven by EditorLoaderEvent
  via the bus).
- Drops `~/Classes/ApiManager.MediaUploadApi` import + media_upload
  field — unused inside the engine; uploads happen via
  adapter.uploadAsset at the call sites.
- Deletes the api_manager + APIManager dependency entirely; SaveManager
  now reads saveSceneState/loadSceneState callbacks directly off
  adapter.saveScene/loadScene.

Scene gains:
- SceneDeps.fetchAsset(url) — replaces `LoadWithoutCors` from
  @storyteller/tauri-api at the load_glb_wrapped_no_cors and
  load_splat_wrapped_no_cors call sites.
- SceneDeps.getMediaUrlByToken(token) — replaces `mediaFilesApi
  .GetMediaFileByToken` at the four getMediaURL call sites.
- Drops MediaFilesApi field; the scene class no longer imports
  `@storyteller/api`.

Adapter interface gains:
- getMediaUrlByToken(token: string): Promise<string>

tsconfig.lib.json relaxes:
- noEmitOnError: false (so the dist still emits for the apps shims
  to resolve through it, while the pre-existing material/texture +
  glb-as-unknown errors that already lived in apps's engine surface
  are tolerated)
- strictNullChecks/noImplicitAny/noUnusedLocals: false (same
  motivation -- the engine code wasn't authored under these strict
  flags; tightening them is a future cleanup)

Apps copies still in place; they still compile against their own
files. The lib's copies are the new canonical source the apps
shims will eventually resolve through. Final integration step
(shimming apps editor.ts/scene.ts/etc to lib + wiring adapter
through EngineProvider + building the new artcraft wrapper) is
the next checkpoint.

Verified: artcraft typecheck still at 124-error baseline.
2026-05-08 14:12:07 -07:00
Tanish Manku 40a6644c35 artcraft: add remaining PageScene enums + models to ui-pagescene lib
Pure additive prep for the editor.ts/scene.ts move. Lib now owns:

- Engine-internal enums (still local to PageScene -- not re-exported
  to artcraft via the ~/enums shim because those aren't shared with
  the rest of the host): MediaFileType + MediaFileSubtype,
  MediaFileClass, WeightCategory, WeightType, Visibility, FetchStatus,
  GenerateTtsAudioErrorType, JobState, ArtStyle.
- Full models surface: assets, mediaInfo, pagination, prompts,
  sceneGenerationMetadata, types, userBookmark + index.

Models imports rewritten on copy:
- mediaInfo.ts: ~/pages/PageScene/enums -> ../enums
- types.tsx: ~/pages/PageScene/enums -> ../enums; ./movies (broken
  pre-existing import) -> ./mediaInfo (where MediaInfo actually lives;
  fixing on the way through since it's a free type fix).

Apps copies stay in place; the lib copies are still orphaned (not
exported from src/index.ts). Proxy folder also tried but needs
engine/scene which isn't in the lib yet -- deferred to the next
checkpoint with editor.ts/scene.ts.

Verified: artcraft typecheck still at 124-error baseline.
2026-05-08 14:05:39 -07:00
Tanish Manku c6a0b43324 artcraft: copy engine subsystems into ui-pagescene lib (still orphaned)
Bulk-copies the pure engine subsystems and their .js helpers into
the lib so they're ready when editor.ts/scene.ts/save_manager.ts
also move (which need adapter wiring for FetchProxy/api_manager
replacement). For now these lib copies are orphaned -- not exported
from src/index.ts, no callers inside the lib yet.

Files copied:
- engine/editor/{HistoryManager, PostProcessingPipeline,
  ViewportController, GizmoController, CameraController}.ts
- engine/{CustomOutlinePass, FindSurfaces, TransformControls,
  chromakey, shaders}.js
- engine/{cameraMath, InfiniteGridHelper, browserChecks,
  placeholder_manager, pickDropPosition}.ts
- engine/KinHelpers/* (FK rigging, kept per direction -- only the
  MediaPipe pose detection was removed earlier)

Imports rewritten on-copy:
- editor/SelectionBridge.ts: ~/enums -> ../../enums (lib-local)
- editor/CameraController.ts: ~/pages/PageScene/enums -> ../../enums

Files NOT yet copied (need editor.ts / scene.ts / sceneGenerationMetadata
to land first, otherwise their type imports don't resolve):
- engine/editor/SelectionBridge.ts (depends on scene_manager_api)
- engine/editor/actions/* (depend on Editor type)
- engine/EditorState/* (depend on scene + sceneGenerationMetadata)

tsconfig adjustments:
- allowJs: true / checkJs: false to support the .js helpers
  (CustomOutlinePass, FindSurfaces, TransformControls, etc.)
- Two pre-existing-flagged errors (PostProcessingPipeline outliner
  uniforms cast, FKHelper TransformControls.camera cast) annotated
  with @ts-expect-error so the lib build emits dist properly under
  the inherited noEmitOnError: true.

Apps versions stay in place untouched -- they still compile against
the apps copies. This is a temporary duplicate-source state that
goes away once editor.ts also moves and the apps subsystems become
re-export shims.

Verified: artcraft typecheck still at 124-error baseline.
2026-05-08 13:50:31 -07:00
Tanish Manku cdb16ff9f4 artcraft: move engine events + EngineStoreBridge into ui-pagescene
The Chromium-style EventListenerMap bus and the per-event-type bridge
both move into the lib. Class identity is single-sourced from
@storyteller/ui-pagescene so emit/subscribe lookups share the same
constructor key whether they're called from inside the lib or from
the artcraft host (e.g. PageEditor's grid-toggle GridVisibleChangedEvent).

The two apps/PageScene/engine files left in place become tiny re-export
shims pointing at the lib so the existing ~50+ importers (engine
internals, action dispatchers, PageEditor) keep working unchanged
during the staged migration -- they'll be deleted in a future commit
once their consumers move into the lib too.

Workspace plumbing:
- frontend/tsconfig.json: nx sync added pagescene to the root project
  references list.
- pagescene/tsconfig.{json,lib.json}: nx sync trimmed the references
  to only the libs the new code actually uses
  (api-enums, model-list, common).
- node_modules/@storyteller/ui-pagescene needs to be a Windows
  directory junction (mklink /J) for the workspace to resolve the
  package; the prior bash `ln -s` produced a copy on Windows that
  didn't reflect the lib's live state and caused misleading
  "no exported member" errors.

Drive-by: nx sync also added a missing `gravatar` reference under
lightbox-modal -- unrelated cleanup riding along.

Verified: artcraft typecheck error count back to 124 baseline (no
new errors from this move).
2026-05-08 13:38:25 -07:00
Tanish Manku bd18c3ec64 artcraft: move PageSceneStore + scene enums into @storyteller/ui-pagescene
The lib now owns the canonical PageSceneStore (Zustand), the
PageScene-specific enums (AssetType, AssetFilterOption, ClipGroup,
CameraAspectRatio, EditorStates), the common datastructures
(XYZ + Simple3DVector), and a minimal models surface (MediaItem +
AudioMediaItem -- enough for the store; richer model types stay in
apps/PageScene/models for now and migrate alongside their consumers).

Old artcraft locations become re-export shims so 56+ existing
importers keep working unchanged during the staged migration:

- apps/.../enums/{AssetType,AssetFilterOption,ClipGroup}.ts re-export
  from @storyteller/ui-pagescene. Single nominal identity for each
  enum across both lib and host -- avoids the "two AssetType types
  that look the same but TypeScript treats as distinct" trap when
  values flow across the boundary (e.g. ImageTo3DExperience passes
  AssetType.SPLAT into PageScene's addObject).
- apps/.../pages/PageScene/PageSceneStore.ts re-exports the store +
  types from the lib.

artcraft/tsconfig.json gains a `references` entry for
libs/components/pagescene so TypeScript resolves the package.
node_modules/@storyteller/ui-pagescene gets its workspace symlink
on first `npm install`; created manually here for the typecheck.

Verified: total typecheck error count unchanged (124, all pre-existing)
-- no new errors from the move.
2026-05-08 13:25:37 -07:00
Tanish Manku 4db39881ee artcraft: drop redundant activeEditorRef in PageScene EngineProvider
The mirror ref served two purposes that turned out to be unnecessary:
the double-create guard `if (activeEditorRef.current) return;` is
unreachable (cleanup nulls the ref before the next effect body runs,
under both normal lifecycle and React 18 StrictMode), and the
cleanup-side fetch can read the editor through closure capture of
the local `newEditor` variable instead.

cacheRef and onSerializeRef stay -- those are latest-prop holders so
the effect can read fresh values on cleanup without putting the
props in deps (which would tear down + rebuild the engine on every
prop change).
2026-05-08 13:16:28 -07:00
BFlat b4da753346 video background change feature (#1480)
- both app and website
2026-05-09 02:31:54 +07:00
Tanish Manku 3a8f44b0d1 artcraft: decouple PageScene EngineProvider from useTabStore
PageScene's engine lifecycle was previously tab-aware: EngineProvider
read tabStore.activeTabId to decide whether to construct or dispose
the Editor, and reached into tabStore.getTabData / updateTabData for
in-memory scene save+restore across tab switches. That hack predates
the post-Stage-A architecture; with the engine event bus + decoupled
Zustand store in place, we can drive the lifecycle from React's own
mount/unmount + a clean prop interface.

Changes:

- EngineProvider takes cacheJsonString + onSceneSerialized props
  instead of importing useTabStore. The construct branch fires once
  the three canvas DOM nodes are populated (sceneContainerEl,
  editorCanvasEl, camViewCanvasEl); the cleanup branch (returned from
  useEffect) snapshots the scene via save_manager.getSceneJson and
  hands the serialized JSON back through onSceneSerialized before
  unmountEngine. No knowledge of tabs.

- SceneContainer + EditorCanvas + CameraViewCanvas callback refs no
  longer drop the unmount-side null. Previously `if (node) { ... }`
  swallowed the null on canvas unmount, leaving stale DOM refs in the
  store and leaking the Editor. Now they pass null through, which
  drives the EngineProvider cleanup effect when the canvases unmount
  (e.g. on tab switch away from 3D).

- PageScene.tsx wraps the new prop interface: reads cacheJsonString
  from useTabStore.getTabData("3D") on render, supplies an
  onSceneSerialized that calls tabStore.updateTabData("3D", json).
  The tab integration moves up one layer; the engine itself is
  storage-agnostic and ready to drop into the @storyteller/ui-pagescene
  lib without modification.

- Deletes pose/CachedPoseLandmarker.ts and pose/CachedFilesetResolver.ts.
  These wrapped @mediapipe/tasks-vision but were fully orphaned (only
  referenced each other; no callers anywhere). FK + KinHelpers stay --
  bone manipulation is still in use, only the MediaPipe-driven pose
  detection is gone.
2026-05-08 12:25:53 -07:00
Tanish Manku 7465d2a533 artcraft: scaffold @storyteller/ui-pagescene library + PageSceneAdapter
Empty Nx library at frontend/libs/components/pagescene/ mirroring the
@storyteller/ui-pagedraw shape (package.json with development export,
tsconfig.lib.json with workspace lib references, vite.config.ts).

Defines the platform-abstraction surface PageSceneAdapter:
- Generation enqueue (model/provider/prompt/tokens/aspect/resolution)
- Scene I/O (saveScene, loadScene, fetchAsset, host CDN/API getters)
- Slot renderers for host-owned modals (renderAssetBrowser,
  renderSceneLoader)
- Optional event hooks (onError, onSceneSaved, onSceneTitleChange,
  onEnqueueMeta, onSelectionChange, onSceneDirty)
- Tab-cache integration (cacheJsonString + onSceneSerialized) so the
  artcraft useTabStore can roundtrip serialized scene JSON without
  the lib knowing about tabs

Library code is empty for now -- file migrations (PageSceneStore +
engine + comps + actions + hooks etc.) land in follow-up commits on
this branch.
2026-05-08 12:09:47 -07:00
Tanish Manku ca713ba1f9 artcraft: introduce 3D engine event bus + EngineStoreBridge boundary
PageScene engine no longer writes to PageSceneStore directly. Engine
subsystems emit typed EngineEvents on a per-event-type bus; a single
EngineStoreBridge subscribes per event class and is the only file in
engine/ that imports usePageSceneStore. Write flow is one-way: UI
toggles emit on the bus, the bridge updates the store, an engine-side
reactor updates the THREE side. No store->engine subscriptions remain.

EngineEventBus is keyed by constructor reference (Map<EventCtor,
Set<Listener>>) so emit() walks only listeners for that event type --
no global fan-out, no instanceof chains. Mirrors Chromium's per-type
EventListenerMap pattern.

MouseControls constructor refactored from 20 positional Function args
to a MouseControlsDeps object before adding bus. SceneUtils,
SaveManager, CameraController, and Scene now take Deps objects with
an explicit bus field.

Lifecycle helpers (set3DPageMounted etc.) moved out of editor.ts into
a non-engine engineLifecycle.ts so engine/ stays store-free. Same for
SceneMetadata (now sceneMetadata.ts at the page root).

Action dispatchers no longer dual-write: addCharacter/addObject/
addShape/setCameraAspect/setTransformMode/toggleObjectLock/
toggleObjectVisibility now emit a single bus event each.

Prerequisite for componentizing PageScene into @storyteller/ui-pagescene.
2026-05-08 12:04:26 -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
Tanish Manku e7dca6eedc 3D: Massive refactor and cleanup (#1447)
- Move to zustand stores from preact signals
- Delete dead code and unused files
- Remove addLocalSplat and follow gallery flow
- Get rid of Timeline components and queues, move to actions
2026-05-08 09:39:19 -07: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
1028 changed files with 32852 additions and 25371 deletions
@@ -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"
}
Generated
+22
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]]
+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,
@@ -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
@@ -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
@@ -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;
@@ -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>,
@@ -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>,
}
@@ -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;
@@ -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,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,44 +9,55 @@ use crate::generate::generate_image::generate_image_response::{
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::PlanFalFlux1Dev;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_dev_edit_image_webhook::{
enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs,
enqueue_flux_1_dev_edit_image_webhook, Flux1DevEditImageArgs, Flux1DevEditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::{
enqueue_flux_1_dev_text_to_image_webhook, Flux1DevArgs,
enqueue_flux_1_dev_text_to_image_webhook, Flux1DevArgs, Flux1DevRequest,
};
pub async fn execute_fal_flux_1_dev(
plan: &PlanFalFlux1Dev,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if let Some(image_url) = &plan.maybe_image_url {
let (webhook_response, outbound_request) = if let Some(image_url) = &plan.maybe_image_url {
// Image-to-image mode
let args = Flux1DevEditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = Flux1DevEditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_url: image_url.clone(),
num_images: plan.num_images.to_edit(),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Flux1DevEditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_dev_edit_image_webhook(args)
let resp = enqueue_flux_1_dev_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
// Text-to-image mode
let args = Flux1DevArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = Flux1DevRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
aspect_ratio: plan.aspect_ratio,
num_images: plan.num_images.to_t2i(),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Flux1DevArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_dev_text_to_image_webhook(args)
let resp = enqueue_flux_1_dev_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,44 +9,55 @@ use crate::generate::generate_image::generate_image_response::{
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::{
enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs,
enqueue_flux_1_schnell_edit_image_webhook, Flux1SchnellEditImageArgs, Flux1SchnellEditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::{
enqueue_flux_1_schnell_text_to_image_webhook, Flux1SchnellArgs,
enqueue_flux_1_schnell_text_to_image_webhook, Flux1SchnellArgs, Flux1SchnellRequest,
};
pub async fn execute_fal_flux_1_schnell(
plan: &PlanFalFlux1Schnell,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if let Some(image_url) = &plan.maybe_image_url {
let (webhook_response, outbound_request) = if let Some(image_url) = &plan.maybe_image_url {
// Image-to-image (redux) mode
let args = Flux1SchnellEditImageArgs {
let request = Flux1SchnellEditImageRequest {
image_url: image_url.clone(),
num_images: plan.num_images.to_edit(),
image_size: plan.edit_image_size,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Flux1SchnellEditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_schnell_edit_image_webhook(args)
let resp = enqueue_flux_1_schnell_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
// Text-to-image mode
let args = Flux1SchnellArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = Flux1SchnellRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
aspect_ratio: plan.t2i_aspect_ratio,
num_images: plan.num_images.to_t2i(),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Flux1SchnellArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_flux_1_schnell_text_to_image_webhook(args)
let resp = enqueue_flux_1_schnell_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,17 +9,21 @@ use crate::generate::generate_image::generate_image_response::{
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::PlanFalFluxPro11;
use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{
enqueue_flux_pro_11_text_to_image_webhook, FluxPro11Args,
enqueue_flux_pro_11_text_to_image_webhook, FluxPro11Args, FluxPro11Request,
};
pub async fn execute_fal_flux_pro_1p1(
plan: &PlanFalFluxPro11,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = FluxPro11Args {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = FluxPro11Request {
prompt: plan.prompt.clone().unwrap_or_default(),
aspect_ratio: plan.aspect_ratio,
num_images: plan.num_images.to_fal(),
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = FluxPro11Args {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
@@ -28,5 +35,6 @@ pub async fn execute_fal_flux_pro_1p1(
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,17 +9,21 @@ use crate::generate::generate_image::generate_image_response::{
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{
enqueue_flux_pro_11_ultra_text_to_image_webhook, FluxPro11UltraArgs,
enqueue_flux_pro_11_ultra_text_to_image_webhook, FluxPro11UltraArgs, FluxPro11UltraRequest,
};
pub async fn execute_fal_flux_pro_1p1_ultra(
plan: &PlanFalFluxPro11Ultra,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = FluxPro11UltraArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = FluxPro11UltraRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
aspect_ratio: plan.aspect_ratio,
num_images: plan.num_images.to_fal(),
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = FluxPro11UltraArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
@@ -28,5 +35,6 @@ pub async fn execute_fal_flux_pro_1p1_ultra(
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,32 +10,39 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs,
EnqueueGptImage1EditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs,
EnqueueGptImage1TextToImageRequest,
};
pub async fn execute_fal_gpt_image_1(
plan: &PlanFalGptImage1,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage1TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueGptImage1TextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
background: None,
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage1TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_text_to_image_webhook(args)
let resp = enqueue_gpt_image_1_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueGptImage1EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueGptImage1EditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_image_url: None,
@@ -41,17 +51,23 @@ pub async fn execute_fal_gpt_image_1(
input_fidelity: None,
background: None,
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage1EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_edit_image_webhook(args)
let resp = enqueue_gpt_image_1_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,32 +10,39 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::PlanFalGptImage1p5;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1p5_edit_image_webhook::{
enqueue_gpt_image_1p5_image_edit_webhook, EnqueueGptImage1p5EditImageArgs,
EnqueueGptImage1p5EditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1p5_text_to_image_webhook::{
enqueue_gpt_image_1p5_text_to_image_webhook, EnqueueGptImage1p5TextToImageArgs,
EnqueueGptImage1p5TextToImageRequest,
};
pub async fn execute_fal_gpt_image_1p5(
plan: &PlanFalGptImage1p5,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage1p5TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueGptImage1p5TextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
background: None,
quality: Some(plan.quality.to_t2i()),
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage1p5TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1p5_text_to_image_webhook(args)
let resp = enqueue_gpt_image_1p5_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueGptImage1p5EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueGptImage1p5EditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_image_url: None,
@@ -41,16 +51,22 @@ pub async fn execute_fal_gpt_image_1p5(
quality: Some(plan.quality.to_edit()),
input_fidelity: None,
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage1p5EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1p5_image_edit_webhook(args)
let resp = enqueue_gpt_image_1p5_image_edit_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,48 +10,61 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::PlanFalGptImage2;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{
enqueue_gpt_image_2_edit_image_webhook, EnqueueGptImage2EditImageArgs,
EnqueueGptImage2EditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{
enqueue_gpt_image_2_text_to_image_webhook, EnqueueGptImage2TextToImageArgs,
EnqueueGptImage2TextToImageRequest,
};
pub async fn execute_fal_gpt_image_2(
plan: &PlanFalGptImage2,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage2TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueGptImage2TextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage2TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_text_to_image_webhook(args)
let resp = enqueue_gpt_image_2_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueGptImage2EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueGptImage2EditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_url: None,
image_size: plan.image_size.map(|s| s.to_edit()),
quality: Some(plan.quality.to_edit()),
output_format: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueGptImage2EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_edit_image_webhook(args)
let resp = enqueue_gpt_image_2_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -9,47 +12,58 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{
enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs,
Gemini25FlashEditNumImages,
Gemini25FlashEditNumImages, Gemini25FlashEditRequest,
};
use fal_client::requests::webhook::image::text::enqueue_gemini_25_flash_text_to_image_webhook::{
enqueue_gemini_25_flash_text_to_image_webhook, Gemini25FlashTextToImageArgs,
Gemini25FlashTextToImageNumImages,
Gemini25FlashTextToImageNumImages, Gemini25FlashTextToImageRequest,
};
pub async fn execute_fal_nano_banana(
plan: &PlanFalNanoBanana,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = Gemini25FlashTextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = Gemini25FlashTextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: to_t2i_num_images(plan.num_images),
aspect_ratio: plan.t2i_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Gemini25FlashTextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gemini_25_flash_text_to_image_webhook(args)
let resp = enqueue_gemini_25_flash_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
// Image-edit mode
let args = Gemini25FlashEditArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = Gemini25FlashEditRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: to_edit_num_images(plan.num_images),
aspect_ratio: plan.edit_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Gemini25FlashEditArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gemini_25_flash_edit_webhook(args)
let resp = enqueue_gemini_25_flash_edit_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -9,49 +12,62 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_2_edit_image_webhook::{
enqueue_nano_banana_2_edit_image_webhook, EnqueueNanoBanana2EditImageArgs,
EnqueueNanoBanana2EditImageNumImages, EnqueueNanoBanana2EditImageResolution,
EnqueueNanoBanana2EditImageNumImages, EnqueueNanoBanana2EditImageRequest,
EnqueueNanoBanana2EditImageResolution,
};
use fal_client::requests::webhook::image::text::enqueue_nano_banana_2_text_to_image_webhook::{
enqueue_nano_banana_2_text_to_image_webhook, EnqueueNanoBanana2TextToImageArgs,
EnqueueNanoBanana2TextToImageNumImages, EnqueueNanoBanana2TextToImageResolution,
EnqueueNanoBanana2TextToImageNumImages, EnqueueNanoBanana2TextToImageRequest,
EnqueueNanoBanana2TextToImageResolution,
};
pub async fn execute_fal_nano_banana_2(
plan: &PlanFalNanaBanana2,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = EnqueueNanoBanana2TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueNanoBanana2TextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: to_t2i_num_images(plan.num_images),
resolution: plan.resolution.map(to_t2i_resolution),
aspect_ratio: plan.t2i_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueNanoBanana2TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_2_text_to_image_webhook(args)
let resp = enqueue_nano_banana_2_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
// Image-edit mode
let args = EnqueueNanoBanana2EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueNanoBanana2EditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: to_edit_num_images(plan.num_images),
resolution: plan.resolution.map(to_edit_resolution),
aspect_ratio: plan.edit_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueNanoBanana2EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_2_edit_image_webhook(args)
let resp = enqueue_nano_banana_2_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -9,48 +12,61 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
};
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{
enqueue_nano_banana_pro_image_edit_webhook, EnqueueNanoBananaProEditImageArgs,
EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution,
EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageRequest,
EnqueueNanoBananaProEditImageResolution,
};
use fal_client::requests::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::{
enqueue_nano_banana_pro_text_to_image_webhook, EnqueueNanoBananaProTextToImageArgs,
EnqueueNanoBananaProTextToImageNumImages, EnqueueNanoBananaProTextToImageResolution,
EnqueueNanoBananaProTextToImageNumImages, EnqueueNanoBananaProTextToImageRequest,
EnqueueNanoBananaProTextToImageResolution,
};
pub async fn execute_fal_nano_banana_pro(
plan: &PlanFalNanaBananaPro,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
// Text-to-image mode
let args = EnqueueNanoBananaProTextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueNanoBananaProTextToImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
num_images: to_t2i_num_images(plan.num_images),
resolution: plan.resolution.map(to_t2i_resolution),
aspect_ratio: plan.t2i_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueNanoBananaProTextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_pro_text_to_image_webhook(args)
let resp = enqueue_nano_banana_pro_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
// Image-edit mode
let args = EnqueueNanoBananaProEditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueNanoBananaProEditImageRequest {
prompt: plan.prompt.as_deref().unwrap_or("").to_string(),
image_urls: plan.image_urls.clone(),
num_images: to_edit_num_images(plan.num_images),
resolution: plan.resolution.map(to_edit_resolution),
aspect_ratio: plan.edit_aspect_ratio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueNanoBananaProEditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_nano_banana_pro_image_edit_webhook(args)
let resp = enqueue_nano_banana_pro_image_edit_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,44 +10,57 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
use fal_client::requests::webhook::image::edit::enqueue_bytedance_seedream_v4_edit_image_webhook::{
enqueue_bytedance_seedream_v4_edit_image_webhook, EnqueueBytedanceSeedreamV4EditImageArgs,
EnqueueBytedanceSeedreamV4EditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_bytedance_seedream_v4_text_to_image_webhook::{
enqueue_bytedance_seedream_v4_text_to_image_webhook, EnqueueBytedanceSeedreamV4TextToImageArgs,
EnqueueBytedanceSeedreamV4TextToImageRequest,
};
pub async fn execute_fal_seedream_4(
plan: &PlanFalSeedream4,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueBytedanceSeedreamV4TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueBytedanceSeedreamV4TextToImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
num_images: Some(plan.num_images.to_t2i()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_t2i()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV4TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v4_text_to_image_webhook(args)
let resp = enqueue_bytedance_seedream_v4_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueBytedanceSeedreamV4EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueBytedanceSeedreamV4EditImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
image_urls: plan.image_urls.clone(),
num_images: Some(plan.num_images.to_edit()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_edit()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV4EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v4_edit_image_webhook(args)
let resp = enqueue_bytedance_seedream_v4_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,44 +10,57 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
use fal_client::requests::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{
enqueue_bytedance_seedream_v4p5_edit_image_webhook, EnqueueBytedanceSeedreamV4p5EditImageArgs,
EnqueueBytedanceSeedreamV4p5EditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_bytedance_seedream_v4p5_text_to_image_webhook::{
enqueue_bytedance_seedream_v4p5_text_to_image_webhook, EnqueueBytedanceSeedreamV4p5TextToImageArgs,
EnqueueBytedanceSeedreamV4p5TextToImageRequest,
};
pub async fn execute_fal_seedream_4p5(
plan: &PlanFalSeedream4p5,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueBytedanceSeedreamV4p5TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueBytedanceSeedreamV4p5TextToImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
num_images: Some(plan.num_images.to_t2i()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_t2i()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV4p5TextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v4p5_text_to_image_webhook(args)
let resp = enqueue_bytedance_seedream_v4p5_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueBytedanceSeedreamV4p5EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueBytedanceSeedreamV4p5EditImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
image_urls: plan.image_urls.clone(),
num_images: Some(plan.num_images.to_edit()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_edit()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV4p5EditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v4p5_edit_image_webhook(args)
let resp = enqueue_bytedance_seedream_v4p5_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -7,44 +10,57 @@ use crate::generate::generate_image::generate_image_response::{
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::PlanFalSeedream5Lite;
use fal_client::requests::webhook::image::edit::enqueue_bytedance_seedream_v5_lite_edit_image_webhook::{
enqueue_bytedance_seedream_v5_lite_edit_image_webhook, EnqueueBytedanceSeedreamV5LiteEditImageArgs,
EnqueueBytedanceSeedreamV5LiteEditImageRequest,
};
use fal_client::requests::webhook::image::text::enqueue_bytedance_seedream_v5_lite_text_to_image_webhook::{
enqueue_bytedance_seedream_v5_lite_text_to_image_webhook, EnqueueBytedanceSeedreamV5LiteTextToImageArgs,
EnqueueBytedanceSeedreamV5LiteTextToImageRequest,
};
pub async fn execute_fal_seedream_5_lite(
plan: &PlanFalSeedream5Lite,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueBytedanceSeedreamV5LiteTextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let (webhook_response, outbound_request) = if plan.image_urls.is_empty() {
let request = EnqueueBytedanceSeedreamV5LiteTextToImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
num_images: Some(plan.num_images.to_t2i()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_t2i()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV5LiteTextToImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v5_lite_text_to_image_webhook(args)
let resp = enqueue_bytedance_seedream_v5_lite_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
} else {
let args = EnqueueBytedanceSeedreamV5LiteEditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
let request = EnqueueBytedanceSeedreamV5LiteEditImageRequest {
prompt: plan.prompt.clone().unwrap_or_default(),
image_urls: plan.image_urls.clone(),
num_images: Some(plan.num_images.to_edit()),
max_images: None,
image_size: plan.image_size.map(|s| s.to_edit()),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueBytedanceSeedreamV5LiteEditImageArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_bytedance_seedream_v5_lite_edit_image_webhook(args)
let resp = enqueue_bytedance_seedream_v5_lite_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
@@ -9,6 +12,11 @@ pub struct ArtcraftImageResponsePayload {
pub struct FalImageResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// The outbound request that was sent to Fal.
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::kling_3p0_pro_multi_function_video_gen::Kling3p0ProMultiFunctionVideoGenDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::text::enqueue_kling_3p0_pro_text_to_video_webhook::{
EnqueueKling3p0ProTextToVideoArgs,
EnqueueKling3p0ProTextToVideoRequest,
EnqueueKling3p0ProTextToVideoDuration,
};
@@ -29,19 +29,16 @@ pub(crate) fn estimate_video_cost_artcraft_kling3p0_pro(
});
// Use text-to-video cost calculator (same pricing for both modes)
let api_key = fal_client::creds::fal_api_key::FalApiKey::from_str("");
let args = EnqueueKling3p0ProTextToVideoArgs {
let req = EnqueueKling3p0ProTextToVideoRequest {
prompt: String::new(),
generate_audio: plan.generate_audio,
negative_prompt: None,
duration,
aspect_ratio: None,
shot_type: None,
webhook_url: "https://example.com",
api_key: &api_key,
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::kling_3p0_standard_multi_function_video_gen::Kling3p0StandardMultiFunctionVideoGenDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
EnqueueKling3p0StandardTextToVideoArgs,
EnqueueKling3p0StandardTextToVideoRequest,
EnqueueKling3p0StandardTextToVideoDuration,
};
@@ -29,19 +29,16 @@ pub(crate) fn estimate_video_cost_artcraft_kling3p0_standard(
});
// Use text-to-video cost calculator (same pricing for both modes)
let api_key = fal_client::creds::fal_api_key::FalApiKey::from_str("");
let args = EnqueueKling3p0StandardTextToVideoArgs {
let req = EnqueueKling3p0StandardTextToVideoRequest {
prompt: String::new(),
generate_audio: plan.generate_audio,
negative_prompt: None,
duration,
aspect_ratio: None,
shot_type: None,
webhook_url: "https://example.com",
api_key: &api_key,
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_1p5_pro_multi_function_video_gen::Seedance1p5ProMultiFunctionVideoGenDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
EnqueueSeedance1p5ProTextToVideoArgs,
EnqueueSeedance1p5ProTextToVideoRequest,
EnqueueSeedance1p5ProTextToVideoDuration,
EnqueueSeedance1p5ProTextToVideoResolution,
};
@@ -33,18 +33,15 @@ pub(crate) fn estimate_video_cost_artcraft_seedance1p5_pro(
});
// Use text-to-video cost calculator (same pricing for both modes)
let api_key = fal_client::creds::fal_api_key::FalApiKey::from_str("");
let args = EnqueueSeedance1p5ProTextToVideoArgs {
let req = EnqueueSeedance1p5ProTextToVideoRequest {
prompt: String::new(),
duration,
resolution,
aspect_ratio: None,
generate_audio: plan.generate_audio,
webhook_url: "https://example.com",
api_key: &api_key,
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,8 +1,7 @@
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteResolution;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
Seedance1LiteArgs, Seedance1LiteDuration, Seedance1LiteResolution,
Seedance1LiteDuration, Seedance1LiteRequest, Seedance1LiteResolution,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::PlanArtcraftSeedance10Lite;
@@ -13,7 +12,6 @@ pub(crate) fn estimate_video_cost_artcraft_seedance_1_0_lite(
) -> VideoGenerationCostEstimate {
// Legacy handler defaults: 5 seconds, 720p. Delegate to the Fal client's
// cost calculator to guarantee parity with what we actually charge.
let api_key = FalApiKey::from_str("");
let duration = if plan.duration_seconds_for_cost() == 10 {
Seedance1LiteDuration::TenSeconds
} else {
@@ -24,21 +22,18 @@ pub(crate) fn estimate_video_cost_artcraft_seedance_1_0_lite(
GenerateSeedance10LiteResolution::SevenTwentyP => Seedance1LiteResolution::SevenTwentyP,
};
let end_frame: Option<&str> = None;
let args = Seedance1LiteArgs {
image_url: "",
end_frame_image_url: end_frame,
prompt: "",
let req = Seedance1LiteRequest {
image_url: String::new(),
end_frame_image_url: None,
prompt: String::new(),
duration,
resolution,
aspect_ratio: None,
camera_fixed: false,
seed: None,
api_key: &api_key,
webhook_url: "https://example.com",
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,6 +1,5 @@
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::Seedance1LiteArgs;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::Seedance1LiteRequest;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_seedance_1_0_lite::PlanFalSeedance10Lite;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -9,22 +8,18 @@ pub(crate) fn estimate_video_cost_fal_seedance_1_0_lite(
plan: &PlanFalSeedance10Lite,
) -> VideoGenerationCostEstimate {
// Delegate to the Fal client's cost calculator to guarantee parity with billing.
let api_key = FalApiKey::from_str("");
let end_frame: Option<&str> = plan.end_image_url.as_deref();
let args = Seedance1LiteArgs {
image_url: plan.image_url.as_str(),
end_frame_image_url: end_frame,
prompt: plan.prompt.as_str(),
let req = Seedance1LiteRequest {
image_url: plan.image_url.clone(),
end_frame_image_url: plan.end_image_url.clone(),
prompt: plan.prompt.clone(),
duration: plan.duration,
resolution: plan.resolution,
aspect_ratio: plan.aspect_ratio,
camera_fixed: false,
seed: None,
api_key: &api_key,
webhook_url: "https://example.com",
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,7 +1,6 @@
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
EnqueueSeedance1p5ProTextToVideoArgs, EnqueueSeedance1p5ProTextToVideoDuration,
EnqueueSeedance1p5ProTextToVideoRequest, EnqueueSeedance1p5ProTextToVideoDuration,
EnqueueSeedance1p5ProTextToVideoResolution,
};
@@ -15,18 +14,15 @@ pub(crate) fn estimate_video_cost_fal_seedance_1p5_pro(
) -> VideoGenerationCostEstimate {
// Seedance 1.5 Pro t2v and i2v have identical pricing; delegate to the t2v
// calculator to guarantee parity with billing.
let api_key = FalApiKey::from_str("");
let args = EnqueueSeedance1p5ProTextToVideoArgs {
let req = EnqueueSeedance1p5ProTextToVideoRequest {
prompt: String::new(),
resolution: plan.resolution.map(to_t2v_resolution),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: None,
generate_audio: plan.generate_audio,
webhook_url: "https://example.com",
api_key: &api_key,
};
let cost_in_usd_cents = args.calculate_cost_in_cents();
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,26 +9,28 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::PlanFalKling16Pro;
use fal_client::requests::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
enqueue_kling_v1p6_pro_image_to_video_webhook, Kling1p6ProArgs,
enqueue_kling_v1p6_pro_image_to_video_webhook, Kling1p6ProArgs, Kling1p6ProRequest,
};
pub async fn execute_fal_kling_1_6_pro(
plan: &PlanFalKling16Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
// The Fal arg type is generic over `Option<T: IntoUrl>` for the end frame URL —
// explicitly bind T to String here.
let end_frame_image_url: Option<String> = plan.end_image_url.clone();
let args = Kling1p6ProArgs {
image_url: plan.image_url.as_str(),
end_frame_image_url,
webhook_url: fal_client.webhook_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
let request = Kling1p6ProRequest {
image_url: plan.image_url.clone(),
end_frame_image_url: plan.end_image_url.clone(),
prompt: plan.prompt.clone(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Kling1p6ProArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
let webhook_response = enqueue_kling_v1p6_pro_image_to_video_webhook(args)
.await
@@ -34,5 +39,6 @@ pub async fn execute_fal_kling_1_6_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,21 +9,27 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_2_1_master::PlanFalKling21Master;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{
enqueue_kling_v2p1_master_image_to_video_webhook, Kling2p1MasterArgs,
enqueue_kling_v2p1_master_image_to_video_webhook, Kling2p1MasterArgs, Kling2p1MasterRequest,
};
pub async fn execute_fal_kling_2_1_master(
plan: &PlanFalKling21Master,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Kling2p1MasterArgs {
image_url: plan.image_url.as_str(),
webhook_url: fal_client.webhook_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
let request = Kling2p1MasterRequest {
image_url: plan.image_url.clone(),
prompt: plan.prompt.clone(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Kling2p1MasterArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
let webhook_response = enqueue_kling_v2p1_master_image_to_video_webhook(args)
.await
@@ -29,5 +38,6 @@ pub async fn execute_fal_kling_2_1_master(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,25 +9,29 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_2_1_pro::PlanFalKling21Pro;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{
enqueue_kling_v2p1_pro_image_to_video_webhook, Kling2p1ProArgs,
enqueue_kling_v2p1_pro_image_to_video_webhook, Kling2p1ProArgs, Kling2p1ProRequest,
};
pub async fn execute_fal_kling_2_1_pro(
plan: &PlanFalKling21Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let end_frame_image_url: Option<String> = plan.end_image_url.clone();
let args = Kling2p1ProArgs {
image_url: plan.image_url.as_str(),
end_frame_image_url,
webhook_url: fal_client.webhook_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
let request = Kling2p1ProRequest {
image_url: plan.image_url.clone(),
end_frame_image_url: plan.end_image_url.clone(),
prompt: plan.prompt.clone(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Kling2p1ProArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
let webhook_response = enqueue_kling_v2p1_pro_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
@@ -32,5 +39,6 @@ pub async fn execute_fal_kling_2_1_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -10,40 +13,49 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_2_
};
use fal_client::requests::webhook::video::image::enqueue_kling_v2p5_turbo_pro_image_to_video_webhook::{
enqueue_kling_v2p5_turbo_pro_image_to_video_webhook, EnqueueKlingV2p5TurboProImageToVideoArgs,
EnqueueKlingV2p5TurboProImageToVideoDurationSeconds,
EnqueueKlingV2p5TurboProImageToVideoRequest, EnqueueKlingV2p5TurboProImageToVideoDurationSeconds,
};
use fal_client::requests::webhook::video::text::enqueue_kling_v2p5_turbo_pro_text_to_video_webhook::{
enqueue_kling_v2p5_turbo_pro_text_to_video_webhook, EnqueueKlingV2p5TurboProTextToVideoArgs,
EnqueueKlingV2p5TurboProTextToVideoAspectRatio, EnqueueKlingV2p5TurboProTextToVideoDurationSeconds,
EnqueueKlingV2p5TurboProTextToVideoRequest, EnqueueKlingV2p5TurboProTextToVideoAspectRatio,
EnqueueKlingV2p5TurboProTextToVideoDurationSeconds,
};
pub async fn execute_fal_kling_2_5_turbo_pro(
plan: &PlanFalKling2p5TurboPro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalKling2p5TurboProMode::TextToVideo => {
let args = EnqueueKlingV2p5TurboProTextToVideoArgs {
let request = EnqueueKlingV2p5TurboProTextToVideoRequest {
prompt: plan.prompt.clone(),
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKlingV2p5TurboProTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_v2p5_turbo_pro_text_to_video_webhook(args).await
(enqueue_kling_v2p5_turbo_pro_text_to_video_webhook(args).await, outbound)
}
FalKling2p5TurboProMode::ImageToVideo { image_url, end_image_url } => {
let args = EnqueueKlingV2p5TurboProImageToVideoArgs {
let request = EnqueueKlingV2p5TurboProImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
tail_image_url: end_image_url.clone(),
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_i2v_duration),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKlingV2p5TurboProImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_v2p5_turbo_pro_image_to_video_webhook(args).await
(enqueue_kling_v2p5_turbo_pro_image_to_video_webhook(args).await, outbound)
}
};
@@ -53,6 +65,7 @@ pub async fn execute_fal_kling_2_5_turbo_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -9,41 +12,50 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_2_
};
use fal_client::requests::webhook::video::image::enqueue_kling_v2p6_pro_image_to_video_webhook::{
enqueue_kling_v2p6_pro_image_to_video_webhook, EnqueueKlingV2p6ProImageToVideoArgs,
EnqueueKlingV2p6ProImageToVideoDurationSeconds,
EnqueueKlingV2p6ProImageToVideoRequest, EnqueueKlingV2p6ProImageToVideoDurationSeconds,
};
use fal_client::requests::webhook::video::text::enqueue_kling_v2p6_pro_text_to_video_webhook::{
enqueue_kling_v2p6_pro_text_to_video_webhook, EnqueueKlingV2p6ProTextToVideoArgs,
EnqueueKlingV2p6ProTextToVideoAspectRatio, EnqueueKlingV2p6ProTextToVideoDurationSeconds,
EnqueueKlingV2p6ProTextToVideoRequest, EnqueueKlingV2p6ProTextToVideoAspectRatio,
EnqueueKlingV2p6ProTextToVideoDurationSeconds,
};
pub async fn execute_fal_kling_2_6_pro(
plan: &PlanFalKling2p6Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalKling2p6ProMode::TextToVideo => {
let args = EnqueueKlingV2p6ProTextToVideoArgs {
let request = EnqueueKlingV2p6ProTextToVideoRequest {
prompt: plan.prompt.clone(),
generate_audio: plan.generate_audio,
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKlingV2p6ProTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_v2p6_pro_text_to_video_webhook(args).await
(enqueue_kling_v2p6_pro_text_to_video_webhook(args).await, outbound)
}
FalKling2p6ProMode::ImageToVideo { image_url } => {
let args = EnqueueKlingV2p6ProImageToVideoArgs {
let request = EnqueueKlingV2p6ProImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
generate_audio: plan.generate_audio,
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_i2v_duration),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKlingV2p6ProImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_v2p6_pro_image_to_video_webhook(args).await
(enqueue_kling_v2p6_pro_image_to_video_webhook(args).await, outbound)
}
};
@@ -53,6 +65,7 @@ pub async fn execute_fal_kling_2_6_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -9,33 +12,39 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_3p
};
use fal_client::requests::webhook::video::image::enqueue_kling_3p0_pro_image_to_video_webhook::{
enqueue_kling_3p0_pro_image_to_video_webhook, EnqueueKling3p0ProImageToVideoArgs,
EnqueueKling3p0ProImageToVideoAspectRatio, EnqueueKling3p0ProImageToVideoDuration,
EnqueueKling3p0ProImageToVideoRequest, EnqueueKling3p0ProImageToVideoAspectRatio,
EnqueueKling3p0ProImageToVideoDuration,
};
use fal_client::requests::webhook::video::text::enqueue_kling_3p0_pro_text_to_video_webhook::{
enqueue_kling_3p0_pro_text_to_video_webhook, EnqueueKling3p0ProTextToVideoArgs,
EnqueueKling3p0ProTextToVideoAspectRatio, EnqueueKling3p0ProTextToVideoDuration,
EnqueueKling3p0ProTextToVideoRequest, EnqueueKling3p0ProTextToVideoAspectRatio,
EnqueueKling3p0ProTextToVideoDuration,
};
pub async fn execute_fal_kling_3p0_pro(
plan: &PlanFalKling3p0Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalKling3p0Mode::TextToVideo => {
let args = EnqueueKling3p0ProTextToVideoArgs {
let request = EnqueueKling3p0ProTextToVideoRequest {
prompt: plan.prompt.clone(),
generate_audio: plan.generate_audio,
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
shot_type: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0ProTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_3p0_pro_text_to_video_webhook(args).await
(enqueue_kling_3p0_pro_text_to_video_webhook(args).await, outbound)
}
FalKling3p0Mode::ImageToVideo { image_url, end_image_url } => {
let args = EnqueueKling3p0ProImageToVideoArgs {
let request = EnqueueKling3p0ProImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
end_image_url: end_image_url.clone(),
@@ -44,10 +53,14 @@ pub async fn execute_fal_kling_3p0_pro(
duration: plan.duration.map(to_i2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
shot_type: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0ProImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_3p0_pro_image_to_video_webhook(args).await
(enqueue_kling_3p0_pro_image_to_video_webhook(args).await, outbound)
}
};
@@ -57,6 +70,7 @@ pub async fn execute_fal_kling_3p0_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -10,33 +13,39 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_3p
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_3p0_standard::PlanFalKling3p0Standard;
use fal_client::requests::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::{
enqueue_kling_3p0_standard_image_to_video_webhook, EnqueueKling3p0StandardImageToVideoArgs,
EnqueueKling3p0StandardImageToVideoAspectRatio, EnqueueKling3p0StandardImageToVideoDuration,
EnqueueKling3p0StandardImageToVideoRequest, EnqueueKling3p0StandardImageToVideoAspectRatio,
EnqueueKling3p0StandardImageToVideoDuration,
};
use fal_client::requests::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
enqueue_kling_3p0_standard_text_to_video_webhook, EnqueueKling3p0StandardTextToVideoArgs,
EnqueueKling3p0StandardTextToVideoAspectRatio, EnqueueKling3p0StandardTextToVideoDuration,
EnqueueKling3p0StandardTextToVideoRequest, EnqueueKling3p0StandardTextToVideoAspectRatio,
EnqueueKling3p0StandardTextToVideoDuration,
};
pub async fn execute_fal_kling_3p0_standard(
plan: &PlanFalKling3p0Standard,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalKling3p0Mode::TextToVideo => {
let args = EnqueueKling3p0StandardTextToVideoArgs {
let request = EnqueueKling3p0StandardTextToVideoRequest {
prompt: plan.prompt.clone(),
generate_audio: plan.generate_audio,
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
shot_type: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0StandardTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_3p0_standard_text_to_video_webhook(args).await
(enqueue_kling_3p0_standard_text_to_video_webhook(args).await, outbound)
}
FalKling3p0Mode::ImageToVideo { image_url, end_image_url } => {
let args = EnqueueKling3p0StandardImageToVideoArgs {
let request = EnqueueKling3p0StandardImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
end_image_url: end_image_url.clone(),
@@ -45,10 +54,14 @@ pub async fn execute_fal_kling_3p0_standard(
duration: plan.duration.map(to_i2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
shot_type: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0StandardImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_kling_3p0_standard_image_to_video_webhook(args).await
(enqueue_kling_3p0_standard_image_to_video_webhook(args).await, outbound)
}
};
@@ -58,6 +71,7 @@ pub async fn execute_fal_kling_3p0_standard(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,24 +9,28 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_seedance_1_0_lite::PlanFalSeedance10Lite;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
enqueue_seedance_1_lite_image_to_video_webhook, Seedance1LiteArgs,
enqueue_seedance_1_lite_image_to_video_webhook, Seedance1LiteArgs, Seedance1LiteRequest,
};
pub async fn execute_fal_seedance_1_0_lite(
plan: &PlanFalSeedance10Lite,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let end_frame: Option<&str> = plan.end_image_url.as_deref();
let args = Seedance1LiteArgs {
image_url: plan.image_url.as_str(),
end_frame_image_url: end_frame,
prompt: plan.prompt.as_str(),
let request = Seedance1LiteRequest {
image_url: plan.image_url.clone(),
end_frame_image_url: plan.end_image_url.clone(),
prompt: plan.prompt.clone(),
duration: plan.duration,
resolution: plan.resolution,
aspect_ratio: plan.aspect_ratio,
camera_fixed: false,
seed: None,
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Seedance1LiteArgs {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
@@ -35,5 +42,6 @@ pub async fn execute_fal_seedance_1_0_lite(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -11,33 +14,37 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_seedance
use fal_client::requests::webhook::video::image::enqueue_seedance_1p5_pro_image_to_video_webhook::{
enqueue_seedance_1p5_pro_image_to_video_webhook, EnqueueSeedance1p5ProImageToVideoArgs,
EnqueueSeedance1p5ProImageToVideoAspectRatio, EnqueueSeedance1p5ProImageToVideoDuration,
EnqueueSeedance1p5ProImageToVideoResolution,
EnqueueSeedance1p5ProImageToVideoRequest, EnqueueSeedance1p5ProImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_seedance_1p5_pro_text_to_video_webhook::{
enqueue_seedance_1p5_pro_text_to_video_webhook, EnqueueSeedance1p5ProTextToVideoArgs,
EnqueueSeedance1p5ProTextToVideoAspectRatio, EnqueueSeedance1p5ProTextToVideoDuration,
EnqueueSeedance1p5ProTextToVideoResolution,
EnqueueSeedance1p5ProTextToVideoRequest, EnqueueSeedance1p5ProTextToVideoAspectRatio,
EnqueueSeedance1p5ProTextToVideoDuration, EnqueueSeedance1p5ProTextToVideoResolution,
};
pub async fn execute_fal_seedance_1p5_pro(
plan: &PlanFalSeedance1p5Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalSeedance1p5ProMode::TextToVideo => {
let args = EnqueueSeedance1p5ProTextToVideoArgs {
let request = EnqueueSeedance1p5ProTextToVideoRequest {
prompt: plan.prompt.clone(),
resolution: plan.resolution.map(to_t2v_resolution),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSeedance1p5ProTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_seedance_1p5_pro_text_to_video_webhook(args).await
(enqueue_seedance_1p5_pro_text_to_video_webhook(args).await, outbound)
}
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
let args = EnqueueSeedance1p5ProImageToVideoArgs {
let request = EnqueueSeedance1p5ProImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
end_image_url: end_image_url.clone(),
@@ -45,10 +52,14 @@ pub async fn execute_fal_seedance_1p5_pro(
duration: plan.duration.map(to_i2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSeedance1p5ProImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_seedance_1p5_pro_image_to_video_webhook(args).await
(enqueue_seedance_1p5_pro_image_to_video_webhook(args).await, outbound)
}
};
@@ -58,6 +69,7 @@ pub async fn execute_fal_seedance_1p5_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -10,42 +13,50 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_sora_2::
use fal_client::requests::webhook::video::image::enqueue_sora_2_image_to_video_webhook::{
enqueue_sora_2_image_to_video_webhook, EnqueueSora2ImageToVideoArgs,
EnqueueSora2ImageToVideoAspectRatio, EnqueueSora2ImageToVideoDurationSeconds,
EnqueueSora2ImageToVideoResolution,
EnqueueSora2ImageToVideoRequest, EnqueueSora2ImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_sora_2_text_to_video_webhook::{
enqueue_sora_2_text_to_video_webhook, EnqueueSora2TextToVideoArgs,
EnqueueSora2TextToVideoAspectRatio, EnqueueSora2TextToVideoDurationSeconds,
EnqueueSora2TextToVideoResolution,
EnqueueSora2TextToVideoRequest, EnqueueSora2TextToVideoAspectRatio,
EnqueueSora2TextToVideoDurationSeconds, EnqueueSora2TextToVideoResolution,
};
pub async fn execute_fal_sora_2(
plan: &PlanFalSora2,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalSora2Mode::TextToVideo => {
// Text-to-video does not support Auto aspect ratio or Auto resolution.
let args = EnqueueSora2TextToVideoArgs {
let request = EnqueueSora2TextToVideoRequest {
prompt: plan.prompt.clone(),
resolution: plan.resolution.and_then(to_t2v_resolution),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.and_then(to_t2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSora2TextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_sora_2_text_to_video_webhook(args).await
(enqueue_sora_2_text_to_video_webhook(args).await, outbound)
}
FalSora2Mode::ImageToVideo { image_url } => {
let args = EnqueueSora2ImageToVideoArgs {
let request = EnqueueSora2ImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
duration: plan.duration.map(to_i2v_duration),
resolution: plan.resolution.map(to_i2v_resolution),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSora2ImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_sora_2_image_to_video_webhook(args).await
(enqueue_sora_2_image_to_video_webhook(args).await, outbound)
}
};
@@ -55,6 +66,7 @@ pub async fn execute_fal_sora_2(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -11,42 +14,50 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_sora_2_p
use fal_client::requests::webhook::video::image::enqueue_sora_2_pro_image_to_video_webhook::{
enqueue_sora_2_pro_image_to_video_webhook, EnqueueSora2ProImageToVideoArgs,
EnqueueSora2ProImageToVideoAspectRatio, EnqueueSora2ProImageToVideoDurationSeconds,
EnqueueSora2ProImageToVideoResolution,
EnqueueSora2ProImageToVideoRequest, EnqueueSora2ProImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_sora_2_pro_text_to_video_webhook::{
enqueue_sora_2_pro_text_to_video_webhook, EnqueueSora2ProTextToVideoArgs,
EnqueueSora2ProTextToVideoAspectRatio, EnqueueSora2ProTextToVideoDurationSeconds,
EnqueueSora2ProTextToVideoResolution,
EnqueueSora2ProTextToVideoRequest, EnqueueSora2ProTextToVideoAspectRatio,
EnqueueSora2ProTextToVideoDurationSeconds, EnqueueSora2ProTextToVideoResolution,
};
pub async fn execute_fal_sora_2_pro(
plan: &PlanFalSora2Pro,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalSora2ProMode::TextToVideo => {
// Text-to-video does not support Auto aspect ratio or Auto resolution.
let args = EnqueueSora2ProTextToVideoArgs {
let request = EnqueueSora2ProTextToVideoRequest {
prompt: plan.prompt.clone(),
resolution: plan.resolution.and_then(to_t2v_resolution),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.and_then(to_t2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSora2ProTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_sora_2_pro_text_to_video_webhook(args).await
(enqueue_sora_2_pro_text_to_video_webhook(args).await, outbound)
}
FalSora2ProMode::ImageToVideo { image_url } => {
let args = EnqueueSora2ProImageToVideoArgs {
let request = EnqueueSora2ProImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: image_url.clone(),
duration: plan.duration.map(to_i2v_duration),
resolution: plan.resolution.map(to_i2v_resolution),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueSora2ProImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_sora_2_pro_image_to_video_webhook(args).await
(enqueue_sora_2_pro_image_to_video_webhook(args).await, outbound)
}
};
@@ -56,6 +67,7 @@ pub async fn execute_fal_sora_2_pro(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -8,48 +11,59 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio,
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio, Veo2Request,
};
use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs,
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2TextToVideoRequest,
};
pub async fn execute_fal_veo_2(
plan: &PlanFalVeo2,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalVeo2Mode::TextToVideo => {
let args = Veo2TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
let request = Veo2TextToVideoRequest {
prompt: plan.prompt.clone(),
negative_prompt: plan.negative_prompt.clone(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio.unwrap_or(Veo2AspectRatio::Auto),
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo2TextToVideoArgs {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_text_to_video_webhook(args)
let resp = enqueue_veo_2_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
}
FalVeo2Mode::ImageToVideo { image_url } => {
// Image-to-video does not support aspect_ratio — the output
// inherits the source image's aspect ratio.
let args = Veo2Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
let request = Veo2Request {
image_url: image_url.to_string(),
prompt: plan.prompt.clone(),
duration: plan.duration,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo2Args {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_image_to_video_webhook(args)
let resp = enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
}
};
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -10,13 +13,67 @@ use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
};
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
Veo3I2vResolution, Veo3Request,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
Veo3TextToVideoArgs,
Veo3TextToVideoArgs, Veo3TextToVideoRequest,
};
pub async fn execute_fal_veo_3(
plan: &PlanFalVeo3,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let (webhook_response, outbound_request) = match &plan.mode {
FalVeo3Mode::TextToVideo => {
let request = Veo3TextToVideoRequest {
prompt: plan.prompt.clone(),
negative_prompt: plan.negative_prompt.clone(),
duration: to_t2v_duration(plan.duration),
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
resolution: to_t2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo3TextToVideoArgs {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
let resp = enqueue_veo_3_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
}
FalVeo3Mode::ImageToVideo { image_url } => {
let request = Veo3Request {
image_url: image_url.to_string(),
prompt: plan.prompt.clone(),
duration: to_i2v_duration(plan.duration),
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
resolution: to_i2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo3Args {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
let resp = enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
(resp, outbound)
}
};
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration {
match d {
FalVeo3Duration::Default => Veo3I2vDuration::Default,
@@ -66,46 +123,3 @@ fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio {
FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen,
}
}
pub async fn execute_fal_veo_3(
plan: &PlanFalVeo3,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
FalVeo3Mode::TextToVideo => {
let args = Veo3TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: to_t2v_duration(plan.duration),
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
resolution: to_t2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo3Mode::ImageToVideo { image_url } => {
let args = Veo3Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
duration: to_i2v_duration(plan.duration),
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
resolution: to_i2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
};
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -6,21 +9,27 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3_fast::PlanFalVeo3Fast;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs,
enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastRequest,
};
pub async fn execute_fal_veo_3_fast(
plan: &PlanFalVeo3Fast,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Veo3FastArgs {
prompt: plan.prompt.as_str(),
image_url: plan.start_frame_url.as_str(),
let request = Veo3FastRequest {
prompt: plan.prompt.clone(),
image_url: plan.start_frame_url.clone(),
aspect_ratio: plan.aspect_ratio,
duration: plan.duration,
api_key: &fal_client.api_key,
resolution: plan.resolution,
generate_audio: plan.generate_audio,
};
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo3FastArgs {
request,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
@@ -31,5 +40,6 @@ pub async fn execute_fal_veo_3_fast(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -12,26 +15,27 @@ use fal_client::requests::webhook::video::image::enqueue_veo_3p1_first_last_fram
EnqueueVeo3p1FirstLastFrameImageToVideoArgs,
EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio,
EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds,
EnqueueVeo3p1FirstLastFrameImageToVideoRequest,
EnqueueVeo3p1FirstLastFrameImageToVideoResolution,
};
use fal_client::requests::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{
enqueue_veo_3p1_image_to_video_webhook, EnqueueVeo3p1ImageToVideoArgs,
EnqueueVeo3p1ImageToVideoAspectRatio, EnqueueVeo3p1ImageToVideoDurationSeconds,
EnqueueVeo3p1ImageToVideoResolution,
EnqueueVeo3p1ImageToVideoRequest, EnqueueVeo3p1ImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{
enqueue_veo_3p1_text_to_video_webhook, EnqueueVeo3p1TextToVideoArgs,
EnqueueVeo3p1TextToVideoAspectRatio, EnqueueVeo3p1TextToVideoDurationSeconds,
EnqueueVeo3p1TextToVideoResolution,
EnqueueVeo3p1TextToVideoRequest, EnqueueVeo3p1TextToVideoAspectRatio,
EnqueueVeo3p1TextToVideoDurationSeconds, EnqueueVeo3p1TextToVideoResolution,
};
pub async fn execute_fal_veo_3p1(
plan: &PlanFalVeo3p1,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_response = match &plan.mode {
let (webhook_response, outbound_request) = match &plan.mode {
FalVeo3p1Mode::TextToVideo => {
let args = EnqueueVeo3p1TextToVideoArgs {
let request = EnqueueVeo3p1TextToVideoRequest {
prompt: plan.prompt.clone(),
duration: plan.duration.map(to_t2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_t2v_aspect_ratio),
@@ -41,26 +45,34 @@ pub async fn execute_fal_veo_3p1(
negative_prompt: plan.negative_prompt.clone(),
seed: None,
auto_fix: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1TextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_text_to_video_webhook(args).await
(enqueue_veo_3p1_text_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::ImageToVideo { start_frame_url } => {
let args = EnqueueVeo3p1ImageToVideoArgs {
let request = EnqueueVeo3p1ImageToVideoRequest {
prompt: plan.prompt.clone(),
image_url: start_frame_url.clone(),
duration: plan.duration.map(to_i2v_duration),
aspect_ratio: plan.aspect_ratio.map(to_i2v_aspect_ratio),
resolution: plan.resolution.map(to_i2v_resolution),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1ImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_image_to_video_webhook(args).await
(enqueue_veo_3p1_image_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::FirstLastFrame { first_frame_url, last_frame_url } => {
let args = EnqueueVeo3p1FirstLastFrameImageToVideoArgs {
let request = EnqueueVeo3p1FirstLastFrameImageToVideoRequest {
prompt: plan.prompt.clone(),
first_frame_url: first_frame_url.clone(),
last_frame_url: last_frame_url.clone(),
@@ -68,10 +80,14 @@ pub async fn execute_fal_veo_3p1(
aspect_ratio: plan.aspect_ratio.map(to_flf_aspect_ratio),
resolution: plan.resolution.map(to_flf_resolution),
generate_audio: plan.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FirstLastFrameImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_first_last_frame_image_to_video_webhook(args).await
(enqueue_veo_3p1_first_last_frame_image_to_video_webhook(args).await, outbound)
}
};
@@ -81,6 +97,7 @@ pub async fn execute_fal_veo_3p1(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -13,17 +16,18 @@ use fal_client::requests::webhook::video::image::enqueue_veo_3p1_fast_first_last
EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs,
EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio,
EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds,
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest,
EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution,
};
use fal_client::requests::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{
enqueue_veo_3p1_fast_image_to_video_webhook, EnqueueVeo3p1FastImageToVideoArgs,
EnqueueVeo3p1FastImageToVideoAspectRatio, EnqueueVeo3p1FastImageToVideoDurationSeconds,
EnqueueVeo3p1FastImageToVideoResolution,
EnqueueVeo3p1FastImageToVideoRequest, EnqueueVeo3p1FastImageToVideoResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{
enqueue_veo_3p1_fast_text_to_video_webhook, EnqueueVeo3p1FastTextToVideoArgs,
EnqueueVeo3p1FastTextToVideoAspectRatio, EnqueueVeo3p1FastTextToVideoDurationSeconds,
EnqueueVeo3p1FastTextToVideoResolution,
EnqueueVeo3p1FastTextToVideoRequest, EnqueueVeo3p1FastTextToVideoAspectRatio,
EnqueueVeo3p1FastTextToVideoDurationSeconds, EnqueueVeo3p1FastTextToVideoResolution,
};
pub async fn execute_fal_veo_3p1_fast(
@@ -32,9 +36,9 @@ pub async fn execute_fal_veo_3p1_fast(
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let inner = &plan.inner;
let webhook_response = match &inner.mode {
let (webhook_response, outbound_request) = match &inner.mode {
FalVeo3p1Mode::TextToVideo => {
let args = EnqueueVeo3p1FastTextToVideoArgs {
let request = EnqueueVeo3p1FastTextToVideoRequest {
prompt: inner.prompt.clone(),
duration: inner.duration.map(to_t2v_duration),
aspect_ratio: inner.aspect_ratio.map(to_t2v_aspect_ratio),
@@ -44,26 +48,34 @@ pub async fn execute_fal_veo_3p1_fast(
negative_prompt: inner.negative_prompt.clone(),
seed: None,
auto_fix: None,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastTextToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_fast_text_to_video_webhook(args).await
(enqueue_veo_3p1_fast_text_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::ImageToVideo { start_frame_url } => {
let args = EnqueueVeo3p1FastImageToVideoArgs {
let request = EnqueueVeo3p1FastImageToVideoRequest {
prompt: inner.prompt.clone(),
image_url: start_frame_url.clone(),
duration: inner.duration.map(to_i2v_duration),
aspect_ratio: inner.aspect_ratio.map(to_i2v_aspect_ratio),
resolution: inner.resolution.map(to_i2v_resolution),
generate_audio: inner.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_fast_image_to_video_webhook(args).await
(enqueue_veo_3p1_fast_image_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::FirstLastFrame { first_frame_url, last_frame_url } => {
let args = EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs {
let request = EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest {
prompt: inner.prompt.clone(),
first_frame_url: first_frame_url.clone(),
last_frame_url: last_frame_url.clone(),
@@ -71,10 +83,14 @@ pub async fn execute_fal_veo_3p1_fast(
aspect_ratio: inner.aspect_ratio.map(to_flf_aspect_ratio),
resolution: inner.resolution.map(to_flf_resolution),
generate_audio: inner.generate_audio,
};
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs {
request,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook(args).await
(enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook(args).await, outbound)
}
};
@@ -84,6 +100,7 @@ pub async fn execute_fal_veo_3p1_fast(
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
@@ -1,3 +1,6 @@
use std::fmt::Debug;
use std::sync::Arc;
use muapi_client::api_types::request_id::RequestId as MuapiRequestId;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
@@ -24,6 +27,11 @@ pub struct Seedance2proVideoResponsePayload {
pub struct FalVideoResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// The outbound request that was sent to Fal.
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
@@ -80,6 +80,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -100,7 +101,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
@@ -222,9 +222,10 @@ mod tests {
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
Some(CommonResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(CommonResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
@@ -81,6 +81,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
// Mismatches
Some(unsupported) => match strategy {
@@ -102,7 +103,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
@@ -185,47 +185,47 @@ mod tests {
// -- Cross-check with Kinovi Fast via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_fast_matches_kinovi_fast_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
//mod cross_check_with_kinovi {
// use super::*;
//
// #[test]
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
// let resolutions = [
// Some(CommonResolution::FourEightyP),
// Some(CommonResolution::SevenTwentyP),
// None,
// ];
// let durations: [u16; 4] = [4, 5, 10, 15];
// let batches: [u16; 3] = [1, 2, 4];
//
// for res in &resolutions {
// for dur in &durations {
// for batch in &batches {
// let artcraft_cost = build_cost(*res, *dur, *batch);
//
// let kinovi = GenerateVideoRequestBuilder {
// model: CommonVideoModel::Seedance2p0Fast,
// provider: Provider::Seedance2Pro,
// resolution: *res,
// duration_seconds: Some(*dur),
// video_batch_count: Some(*batch),
// ..Default::default()
// };
// let kinovi_cost = kinovi.build2()
// .expect("kinovi build2")
// .estimate_cost()
// .expect("kinovi estimate_cost");
//
// assert_eq!(
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
// "USD cents mismatch: res={:?} dur={}s batch={}",
// res, dur, batch,
// );
// }
// }
// }
// }
//}
// -- Helpers --
@@ -1,4 +1,8 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -73,6 +77,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -95,7 +100,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
@@ -193,7 +197,11 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,22 +1,23 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
KinoviSeedance2p0OutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
pub struct KinoviSeedance2p0CostState {
pub resolution: KinoviOutputResolution,
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
pub has_video_reference: bool,
}
impl KinoviSeedance2p0CostState {
pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -32,27 +33,22 @@ impl KinoviSeedance2p0CostState {
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
batch_count: Some(draft.batch_count),
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Pro,
output_resolution: Some(self.resolution),
let pricing_request = GenerateSeedance2p0Request {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -62,8 +58,8 @@ impl KinoviSeedance2p0CostState {
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -79,6 +75,11 @@ impl KinoviSeedance2p0CostState {
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
};
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -93,26 +94,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 80);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 96);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 112);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 240);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 128);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 480);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 256);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 320);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 960);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
}
}
@@ -202,9 +203,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0CostState {
resolution: KinoviOutputResolution::SevenTwentyP,
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
batch_count: Some(KinoviBatchCount::One),
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -222,19 +223,19 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -257,7 +258,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -277,18 +278,18 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
@@ -296,8 +297,8 @@ mod tests {
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::TenEightyP));
assert!(matches!(cost.batch_count, KinoviBatchCount::Four));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
}
@@ -307,7 +308,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -322,8 +323,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(480));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
}
}
@@ -382,7 +383,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -393,7 +397,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -443,13 +450,12 @@ mod tests {
};
KinoviSeedance2p0RequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
request: GenerateSeedance2p0Request {
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds,
batch_count,
batch_count: Some(batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,4 +1,7 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -19,10 +22,10 @@ pub struct KinoviSeedance2p0DraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub aspect_ratio: KinoviSeedance2p0AspectRatio,
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub batch_count: KinoviSeedance2p0BatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
@@ -77,13 +80,12 @@ impl KinoviSeedance2p0DraftState {
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
let request = GenerateSeedance2p0Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
batch_count: Some(self.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,28 +1,31 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
pub request: GenerateSeedance2p0Request,
}
impl KinoviSeedance2p0RequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
let args = GenerateSeedance2p0Args {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
request: self.request.clone(),
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_seedance_2p0(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -32,320 +35,3 @@ impl KinoviSeedance2p0RequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
const STEAMPUNK_CLOWN_KINOVI_ID: &str = "char_1775176566518_sik0te";
const MOCHI_KINOVI_ID: &str = "char_1775177718294_g2pitx";
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_reference() {
// NB: This URL is a pre-uploaded video on the Seedance2Pro CDN from prior tests.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let char_clown = CharacterToken::new("char_clown".to_string());
let char_mochi = CharacterToken::new("char_mochi".to_string());
let mut character_map = HashMap::new();
character_map.insert(char_clown.clone(), STEAMPUNK_CLOWN_KINOVI_ID.to_string());
character_map.insert(char_mochi.clone(), MOCHI_KINOVI_ID.to_string());
let response = run_pipeline_with_maps(
GenerateVideoRequestBuilder {
prompt: Some("@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
char_clown,
char_mochi,
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
},
HashMap::new(),
character_map,
).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a web-accessible audio URL is available in test_data.
}
// ── Helpers ──
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder → build2 → finalize → send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with both a media map and character map.
async fn run_pipeline_with_maps(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
character_map: HashMap<CharacterToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
character_token_to_kinovi_id_map: Some(&character_map),
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,4 +1,8 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -73,6 +77,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -95,7 +100,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
@@ -208,7 +212,11 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,22 +1,23 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
KinoviSeedance2p0FastOutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
pub struct KinoviSeedance2p0FastCostState {
pub resolution: KinoviOutputResolution,
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
pub has_video_reference: bool,
}
impl KinoviSeedance2p0FastCostState {
pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -32,27 +33,22 @@ impl KinoviSeedance2p0FastCostState {
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
batch_count: Some(draft.batch_count),
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Fast,
output_resolution: Some(self.resolution),
let pricing_request = GenerateSeedance2p0FastRequest {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -62,8 +58,8 @@ impl KinoviSeedance2p0FastCostState {
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -80,6 +76,11 @@ impl KinoviSeedance2p0FastCostState {
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
};
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -90,51 +91,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) --
// -- 720p (28 credits/sec, 193 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
}
}
// -- 480p (new pricing: 193 credits/$1, 10 credits/sec) --
// -- 480p (14 credits/sec, 193 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
}
}
@@ -174,9 +175,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0FastCostState {
resolution: KinoviOutputResolution::SevenTwentyP,
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
batch_count: Some(KinoviBatchCount::One),
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -194,26 +195,26 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -222,7 +223,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -242,19 +243,19 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(cost.resolution.is_none());
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -263,7 +264,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -278,8 +279,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
}
}
@@ -322,7 +323,7 @@ mod tests {
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
}
}
@@ -333,7 +334,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -344,7 +348,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -395,13 +402,12 @@ mod tests {
};
KinoviSeedance2p0FastRequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
request: GenerateSeedance2p0FastRequest {
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds,
batch_count,
batch_count: Some(batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,4 +1,7 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -16,15 +19,12 @@ use crate::generate::generate_video_v2::video_generation_draft_context::VideoGen
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastDraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub aspect_ratio: KinoviSeedance2p0FastAspectRatio,
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub batch_count: KinoviSeedance2p0FastBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -77,13 +77,12 @@ impl KinoviSeedance2p0FastDraftState {
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
let request = GenerateSeedance2p0FastRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
batch_count: Some(self.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,28 +1,31 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastRequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
pub request: GenerateSeedance2p0FastRequest,
}
impl KinoviSeedance2p0FastRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
let args = GenerateSeedance2p0FastArgs {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
request: self.request.clone(),
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_seedance_2p0_fast(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -32,229 +35,3 @@ impl KinoviSeedance2p0FastRequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder -> build2 -> finalize -> send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,24 @@
# beeble_client
HTTP client for the Beeble SwitchX compositing API.
## API Reference
- Docs: https://developer.beeble.ai/docs
- OpenAPI: https://api.beeble.ai/developer-api-docs/openapi.json
- Auth: `x-api-key` header
## Endpoints
- `create_upload_url` — POST /v1/uploads — get a presigned PUT URL for uploading media
- `start_generation` — POST /v1/switchx/generations — start an image or video compositing job
- `get_job_status` — GET /v1/switchx/generations/{job_id} — poll job status until completed/failed
## Testing
```bash
cargo test -p beeble_client # run non-ignored tests (API shape only)
cargo test -p beeble_client -- --ignored # run live API tests (requires key, incurs costs)
```
Live tests require: `/Users/bt/Artcraft/credentials/beeble_api_key.txt`
@@ -0,0 +1,31 @@
[package]
name = "beeble_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "beeble_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
log.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
anyhow.workspace = true
test_data.workspace = true
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,24 @@
use std::fmt;
/// Holds the API key for authenticating with the Beeble API.
/// Sent as the `x-api-key` header.
#[derive(Clone)]
pub struct BeebleApiKey {
pub(crate) api_key: String,
}
impl BeebleApiKey {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
impl fmt::Debug for BeebleApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.api_key.len() > 0 {
write!(f, "BeebleApiKey(***)")
} else {
write!(f, "BeebleApiKey(EMPTY)")
}
}
}
@@ -0,0 +1 @@
pub mod beeble_api_key;
@@ -0,0 +1,26 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum BeebleClientError {
/// No API key is present.
NoApiKeyPresent,
/// An error was encountered in building the reqwest client.
ReqwestClientError(reqwest::Error),
/// A serialization error with the request.
SerializationError(serde_json::Error),
}
impl Error for BeebleClientError {}
impl Display for BeebleClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key present."),
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
Self::SerializationError(err) => write!(f, "Serialization error: {}", err),
}
}
}
@@ -0,0 +1,41 @@
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use std::error::Error;
#[derive(Debug)]
pub enum BeebleError {
Client(BeebleClientError),
ApiSpecific(BeebleSpecificApiError),
ApiGeneric(BeebleGenericApiError),
}
impl Error for BeebleError {}
impl std::fmt::Display for BeebleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "BeebleClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "BeebleSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "BeebleGenericApiError: {:?}", e),
}
}
}
impl From<BeebleClientError> for BeebleError {
fn from(error: BeebleClientError) -> Self {
Self::Client(error)
}
}
impl From<BeebleSpecificApiError> for BeebleError {
fn from(error: BeebleSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<BeebleGenericApiError> for BeebleError {
fn from(error: BeebleGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,34 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Catch-all for API errors that don't fit a specific variant.
#[derive(Debug)]
pub enum BeebleGenericApiError {
/// An HTTP transport error from reqwest.
ReqwestError(reqwest::Error),
/// Failed to parse the response body as JSON.
SerdeResponseParseError(serde_json::Error, String),
/// The API returned a non-success status code.
UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode,
body: String,
},
}
impl Error for BeebleGenericApiError {}
impl Display for BeebleGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReqwestError(err) => write!(f, "Beeble reqwest error: {}", err),
Self::SerdeResponseParseError(err, body) => {
write!(f, "Beeble response parse error: {} (body: {})", err, body)
}
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
write!(f, "Beeble API error: status={}, body={}", status_code, body)
}
}
}
}
@@ -0,0 +1,54 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Well-known API errors from the Beeble service.
#[derive(Debug)]
pub enum BeebleSpecificApiError {
/// 401 Unauthorized — invalid or missing API key.
Unauthorized,
/// 402 Payment Required — insufficient credits.
InsufficientCredits,
/// 409 Conflict — idempotency key reused with different parameters.
IdempotencyConflict,
/// 429 Too Many Requests — rate limit exceeded.
RateLimited,
/// The generation job failed with a reported error message.
GenerationFailed(String),
/// 400 Bad Request — the callback_url is not a valid publicly-reachable HTTPS URL.
BadWebhookUrl {
message: String,
webhook_url: String,
},
/// 400 Bad Request — the video has too many frames.
VideoHasTooManyFrames {
max_frames: u64,
detected_frames: u64,
message: String,
},
}
impl Error for BeebleSpecificApiError {}
impl Display for BeebleSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized => write!(f, "Beeble API: Unauthorized (invalid or missing API key)"),
Self::InsufficientCredits => write!(f, "Beeble API: Insufficient credits"),
Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"),
Self::RateLimited => write!(f, "Beeble API: Rate limited"),
Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg),
Self::BadWebhookUrl { message, webhook_url } => {
write!(f, "Beeble API: Bad webhook URL '{}': {}", webhook_url, message)
}
Self::VideoHasTooManyFrames { max_frames, detected_frames, message } => {
write!(f, "Beeble API: Video has too many frames ({} detected, max {}): {}", detected_frames, max_frames, message)
}
}
}
}
@@ -0,0 +1,4 @@
pub mod beeble_client_error;
pub mod beeble_error;
pub mod beeble_generic_api_error;
pub mod beeble_specific_api_error;
@@ -0,0 +1,8 @@
#[cfg(test)]
pub(crate) mod test_utils;
// Public API
pub mod creds;
pub mod error;
pub mod requests;
pub mod webhook_api;
@@ -0,0 +1,126 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use crate::requests::create_upload_url::request_types::*;
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public args ──
pub struct CreateUploadUrlArgs {
pub api_key: BeebleApiKey,
pub filename: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct CreateUploadUrlSuccess {
/// Upload ID (upload_...).
pub id: String,
/// Presigned PUT URL for uploading. Expires after 1 hour.
pub upload_url: String,
/// beeble:// URI to reference this upload in generation calls.
pub beeble_uri: String,
}
// ── Implementation ──
/// Create a presigned upload URL for a media file.
///
/// After receiving the URL, PUT your file bytes directly to `upload_url`.
/// Then use `beeble_uri` as the source_uri, reference_image_uri, or alpha_uri
/// in generation calls.
pub async fn create_upload_url(args: CreateUploadUrlArgs) -> Result<CreateUploadUrlSuccess, BeebleError> {
let url = format!("{}/uploads", BEEBLE_API_BASE_URL);
info!("Creating Beeble upload URL for filename: {}", args.filename);
let request_body = CreateUploadUrlRequestBody {
filename: args.filename,
};
let client = reqwest::Client::builder()
.build()
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
let response = client.post(&url)
.header("x-api-key", &args.api_key.api_key)
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
info!("Beeble create upload URL response: status={}", status);
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(BeebleSpecificApiError::Unauthorized.into());
}
if !status.is_success() {
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(CreateUploadUrlSuccess {
id: parsed.id,
upload_url: parsed.upload_url,
beeble_uri: parsed.beeble_uri,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use errors::AnyhowResult;
#[test]
fn request_body_serializes() {
let body = CreateUploadUrlRequestBody {
filename: "test_video.mp4".to_string(),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("test_video.mp4"));
}
#[test]
fn response_body_deserializes() {
let json = r#"{"id":"upload_abc123","upload_url":"https://example.com/put","beeble_uri":"beeble://upload_abc123/video.mp4"}"#;
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "upload_abc123");
assert_eq!(parsed.beeble_uri, "beeble://upload_abc123/video.mp4");
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_upload_url() -> AnyhowResult<()> {
let api_key = get_test_api_key()?;
let result = create_upload_url(CreateUploadUrlArgs {
api_key,
filename: "test_image.png".to_string(),
}).await?;
println!("Upload ID: {}", result.id);
println!("Upload URL: {}", result.upload_url);
println!("Beeble URI: {}", result.beeble_uri);
assert!(result.id.starts_with("upload_"));
assert!(result.beeble_uri.starts_with("beeble://"));
Ok(())
}
}
@@ -0,0 +1,3 @@
pub mod create_upload_url;
mod request_types;
pub mod upload_bytes;
@@ -0,0 +1,13 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct CreateUploadUrlRequestBody {
pub filename: String,
}
#[derive(Deserialize, Debug)]
pub struct CreateUploadUrlResponseBody {
pub id: String,
pub upload_url: String,
pub beeble_uri: String,
}
@@ -0,0 +1,50 @@
use log::info;
use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::requests::create_upload_url::create_upload_url::{
create_upload_url, CreateUploadUrlArgs, CreateUploadUrlSuccess,
};
/// Upload bytes to Beeble in one call (create presigned URL + PUT bytes).
///
/// Returns the upload success with the `beeble_uri` to use in generation calls.
pub async fn upload_bytes_to_beeble(
api_key: &BeebleApiKey,
filename: &str,
content_type: &str,
bytes: Vec<u8>,
) -> Result<CreateUploadUrlSuccess, BeebleError> {
info!("Uploading {} bytes to Beeble as {}", bytes.len(), filename);
// 1. Get presigned URL.
let upload = create_upload_url(CreateUploadUrlArgs {
api_key: api_key.clone(),
filename: filename.to_string(),
}).await?;
info!("Beeble upload URL obtained: {}", upload.beeble_uri);
// 2. PUT bytes to presigned URL.
let client = reqwest::Client::new();
let put_response = client.put(&upload.upload_url)
.header("Content-Type", content_type)
.body(bytes)
.send()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = put_response.status();
if !status.is_success() {
let body = put_response.text().await.unwrap_or_default();
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body,
}.into());
}
info!("Beeble upload PUT succeeded: {}", upload.beeble_uri);
Ok(upload)
}

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