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
528 changed files with 14857 additions and 19567 deletions
@@ -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"
}
Generated
+17
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",
+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,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
@@ -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;
@@ -0,0 +1,20 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse, BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
};
pub async fn beeble_switchx_edit_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: BeebleSwitchXEditVideoRequest,
) -> Result<BeebleSwitchXEditVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
BEEBLE_SWITCHX_EDIT_VIDEO_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video;
@@ -1,3 +1,4 @@
pub mod edit;
pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_21_master_image_to_video;
pub mod generate_kling_21_pro_image_to_video;
@@ -0,0 +1,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)
}
@@ -0,0 +1,104 @@
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::start_generation::request_types::GenerationJobResponseBody;
use crate::requests::start_generation::start_generation::{map_job_response, StartGenerationSuccess};
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public args ──
pub struct GetJobStatusArgs {
pub api_key: BeebleApiKey,
/// The job ID (swx_...) to poll.
pub job_id: String,
}
// ── Implementation ──
/// Poll the status of a SwitchX generation job.
///
/// Returns the same response shape as start_generation.
/// Poll until `status` is "completed" or "failed".
pub async fn get_job_status(args: GetJobStatusArgs) -> Result<StartGenerationSuccess, BeebleError> {
let url = format!("{}/switchx/generations/{}", BEEBLE_API_BASE_URL, args.job_id);
info!("Polling Beeble job status: job_id={}", args.job_id);
let client = reqwest::Client::builder()
.build()
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
let response = client.get(&url)
.header("x-api-key", &args.api_key.api_key)
.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 get job status response: status={}", status);
match status.as_u16() {
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
_ if !status.is_success() => {
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
_ => {}
}
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(map_job_response(parsed))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
//const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF"; // Production
//const TEST_JOB_ID: &str = "swx_Ontb6KfbfdY24Cz8DZ9c8"; // Dev
//const TEST_JOB_ID: &str = "swx_k5SmgdFcesuE05BE8VqFb"; // Dev
//const TEST_JOB_ID: &str = "swx_snkg7Et8EOvWaQ0QgoZlZ"; // Dev
const TEST_JOB_ID: &str = "swx_a1GBhEHwIfhzXfWEkEbcb"; // Dev
//const TEST_JOB_ID: &str = "swx_9Cbr7coiW9ZOayIrKGe0v"; // Prod
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_poll_known_job() -> errors::AnyhowResult<()> {
let api_key = get_test_api_key()?;
let result = get_job_status(GetJobStatusArgs {
api_key,
job_id: TEST_JOB_ID.to_string(),
}).await?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
println!("Generation type: {:?}", result.generation_type);
println!("Alpha mode: {:?}", result.alpha_mode);
println!("Error: {:?}", result.error);
println!("Created at: {:?}", result.created_at);
if let Some(output) = &result.output {
println!("Render URL: {:?}", output.render);
println!("Source URL: {:?}", output.source);
println!("Alpha URL: {:?}", output.alpha);
}
assert_eq!(result.id, TEST_JOB_ID);
Ok(())
}
}
@@ -0,0 +1 @@
pub mod get_job_status;
@@ -0,0 +1,3 @@
pub mod create_upload_url;
pub mod get_job_status;
pub mod start_generation;
@@ -0,0 +1,72 @@
use reqwest::StatusCode;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
/// Additional context for error handling that depends on the original request.
pub struct ErrorContext <'a> {
pub maybe_callback_url: Option<&'a str>,
}
/// Classify a non-success Beeble API response into the appropriate error.
///
/// Only call this when `!status.is_success()`. Always returns `Err`.
pub fn handle_error_response<T>(
status: StatusCode,
response_body: &str,
context: &ErrorContext,
) -> Result<T, BeebleError> {
match status.as_u16() {
401 => Err(BeebleSpecificApiError::Unauthorized.into()),
402 => Err(BeebleSpecificApiError::InsufficientCredits.into()),
400 if response_body.contains("INVALID_CALLBACK_URL") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "callback_url must be a valid, publicly-reachable HTTPS URL".to_string());
Err(BeebleSpecificApiError::BadWebhookUrl {
message,
webhook_url: context.maybe_callback_url.unwrap_or_default().to_string(),
}.into())
}
400 if response_body.contains("VIDEO_TOO_MANY_FRAMES") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "Video has too many frames".to_string());
let (detected, max) = extract_frame_counts(&message);
Err(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames: max,
detected_frames: detected,
message,
}.into())
}
409 => Err(BeebleSpecificApiError::IdempotencyConflict.into()),
429 => Err(BeebleSpecificApiError::RateLimited.into()),
_ => {
Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body.to_string(),
}.into())
}
}
}
/// Try to extract the "message" field from a Beeble error response body.
/// Beeble errors look like: `{"error":{"message":"...","code":"..."}}`
fn extract_error_message(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
/// Parse frame counts from a message like "Video has 419 frames, which exceeds the maximum of 240."
fn extract_frame_counts(message: &str) -> (u64, u64) {
let numbers: Vec<u64> = message
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
match numbers.as_slice() {
[detected, max, ..] => (*detected, *max),
[detected] => (*detected, 0),
_ => (0, 0),
}
}
@@ -0,0 +1,3 @@
pub(crate) mod handle_error_response;
pub(crate) mod request_types;
pub mod start_generation;
@@ -0,0 +1,55 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct StartGenerationRequestBody {
pub generation_type: String,
pub source_uri: String,
pub alpha_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_image_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alpha_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_resolution: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GenerationJobResponseBody {
pub id: String,
pub status: String,
pub progress: Option<u8>,
pub generation_type: Option<String>,
pub alpha_mode: Option<String>,
pub output: Option<GenerationOutput>,
pub error: Option<String>,
pub created_at: Option<String>,
pub modified_at: Option<String>,
pub completed_at: Option<String>,
pub webhook: Option<WebhookStatus>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GenerationOutput {
pub render: Option<String>,
pub source: Option<String>,
pub alpha: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookStatus {
pub status: Option<String>,
pub attempts: Option<u32>,
pub last_error: Option<String>,
}
@@ -0,0 +1,395 @@
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::start_generation::handle_error_response::{handle_error_response, ErrorContext};
use crate::requests::start_generation::request_types::*;
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
// ── Public types ──
#[derive(Clone, Debug)]
pub struct StartGenerationArgs {
pub api_key: BeebleApiKey,
pub request: StartGenerationRequest,
}
#[derive(Clone, Debug)]
pub struct StartGenerationRequest {
/// "image" or "video".
pub generation_type: BeebleGenerationType,
/// Source image/video URI. Supports beeble://, https://, data: base64.
pub source_uri: String,
/// Alpha matte mode.
pub alpha_mode: BeebleAlphaMode,
/// Text description of desired output (max 2000 chars).
pub prompt: Option<String>,
/// URI for style transfer reference image.
pub reference_image_uri: Option<String>,
/// Custom alpha matte URI. Required for Custom and Select modes.
pub alpha_uri: Option<String>,
/// Maximum output resolution: 720 or 1080. Default 1080.
pub max_resolution: Option<u16>,
/// HTTPS webhook URL for completion notification.
pub callback_url: Option<String>,
/// Safe retry key preventing duplicate jobs (max 256 chars).
pub idempotency_key: Option<String>,
}
#[derive(Copy, Clone, Debug)]
pub enum BeebleGenerationType {
Image,
Video,
}
#[derive(Copy, Clone, Debug)]
pub enum BeebleAlphaMode {
/// Automatic alpha detection.
Auto,
/// Fill the entire frame.
Fill,
/// Use a custom alpha matte (requires alpha_uri).
Custom,
/// Select regions from the source (requires alpha_uri).
Select,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct StartGenerationSuccess {
/// Job identifier (swx_...).
pub id: String,
/// in_queue, processing, completed, or failed.
pub status: String,
/// 0-100 progress percentage.
pub progress: Option<u8>,
pub generation_type: Option<String>,
pub alpha_mode: Option<String>,
pub output: Option<GenerationOutputResult>,
pub error: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GenerationOutputResult {
/// URL to the rendered output. Expires after 72 hours.
pub render: Option<String>,
/// URL to the source. Expires after 72 hours.
pub source: Option<String>,
/// URL to the alpha matte. Expires after 72 hours.
pub alpha: Option<String>,
}
// ── Implementation ──
/// Start a SwitchX compositing generation job.
pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerationSuccess, BeebleError> {
let url = format!("{}/switchx/generations", BEEBLE_API_BASE_URL);
let req = args.request;
info!("Starting Beeble generation: type={:?}, alpha_mode={:?}", req.generation_type, req.alpha_mode);
let request_body = StartGenerationRequestBody {
generation_type: match req.generation_type {
BeebleGenerationType::Image => "image".to_string(),
BeebleGenerationType::Video => "video".to_string(),
},
source_uri: req.source_uri,
alpha_mode: match req.alpha_mode {
BeebleAlphaMode::Auto => "auto".to_string(),
BeebleAlphaMode::Fill => "fill".to_string(),
BeebleAlphaMode::Custom => "custom".to_string(),
BeebleAlphaMode::Select => "select".to_string(),
},
prompt: req.prompt,
reference_image_uri: req.reference_image_uri,
alpha_uri: req.alpha_uri,
max_resolution: req.max_resolution,
callback_url: req.callback_url.clone(),
idempotency_key: req.idempotency_key,
};
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 start generation response: status={}", status);
if !status.is_success() {
return handle_error_response(status, &response_body, &ErrorContext {
maybe_callback_url: req.callback_url.as_deref(),
});
}
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
Ok(map_job_response(parsed))
}
pub(crate) fn map_job_response(parsed: GenerationJobResponseBody) -> StartGenerationSuccess {
StartGenerationSuccess {
id: parsed.id,
status: parsed.status,
progress: parsed.progress,
generation_type: parsed.generation_type,
alpha_mode: parsed.alpha_mode,
output: parsed.output.map(|o| GenerationOutputResult {
render: o.render,
source: o.source,
alpha: o.alpha,
}),
error: parsed.error,
created_at: parsed.created_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::start_generation::request_types::GenerationJobResponseBody;
#[test]
fn request_body_serializes_minimal() {
let body = StartGenerationRequestBody {
generation_type: "image".to_string(),
source_uri: "beeble://upload_abc/photo.jpg".to_string(),
alpha_mode: "auto".to_string(),
prompt: None,
reference_image_uri: None,
alpha_uri: None,
max_resolution: None,
callback_url: None,
idempotency_key: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"generation_type\":\"image\""));
assert!(json.contains("\"alpha_mode\":\"auto\""));
// Optional fields should not be present
assert!(!json.contains("prompt"));
assert!(!json.contains("reference_image_uri"));
}
#[test]
fn request_body_serializes_full() {
let body = StartGenerationRequestBody {
generation_type: "video".to_string(),
source_uri: "https://example.com/video.mp4".to_string(),
alpha_mode: "custom".to_string(),
prompt: Some("A sunny beach".to_string()),
reference_image_uri: Some("beeble://upload_ref/ref.jpg".to_string()),
alpha_uri: Some("beeble://upload_alpha/mask.png".to_string()),
max_resolution: Some(1080),
callback_url: Some("https://example.com/webhook".to_string()),
idempotency_key: Some("my-key-123".to_string()),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"prompt\":\"A sunny beach\""));
assert!(json.contains("\"max_resolution\":1080"));
assert!(json.contains("\"idempotency_key\":\"my-key-123\""));
}
#[test]
fn response_body_deserializes_queued() {
let json = r#"{
"id": "swx_abc123",
"status": "in_queue",
"progress": null,
"generation_type": "image",
"alpha_mode": "auto",
"output": null,
"error": null,
"created_at": "2026-05-08T00:00:00Z",
"modified_at": "2026-05-08T00:00:00Z",
"completed_at": null,
"webhook": null
}"#;
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "swx_abc123");
assert_eq!(parsed.status, "in_queue");
assert!(parsed.output.is_none());
}
#[test]
fn response_body_deserializes_completed() {
let json = r#"{
"id": "swx_abc123",
"status": "completed",
"progress": 100,
"generation_type": "image",
"alpha_mode": "auto",
"output": {
"render": "https://cdn.beeble.ai/render.png",
"source": "https://cdn.beeble.ai/source.png",
"alpha": "https://cdn.beeble.ai/alpha.png"
},
"error": null,
"created_at": "2026-05-08T00:00:00Z",
"modified_at": "2026-05-08T00:00:01Z",
"completed_at": "2026-05-08T00:00:01Z",
"webhook": null
}"#;
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.status, "completed");
assert_eq!(parsed.progress, Some(100));
let output = parsed.output.unwrap();
assert!(output.render.unwrap().contains("render.png"));
}
#[tokio::test]
#[ignore] // manually test — requires real API key and incurs costs
async fn test_start_image_generation() -> errors::AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
let api_key = get_test_api_key()?;
let result = start_generation(StartGenerationArgs {
api_key,
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Image,
source_uri: "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?w=800".to_string(),
alpha_mode: BeebleAlphaMode::Auto,
prompt: Some("A tropical beach at sunset".to_string()),
reference_image_uri: None,
alpha_uri: None,
max_resolution: Some(720),
callback_url: None,
idempotency_key: None,
},
}).await?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
assert!(result.id.starts_with("swx_"));
Ok(())
}
// ── Helpers for end-to-end tests ──
async fn download_bytes(url: &str) -> errors::AnyhowResult<Vec<u8>> {
let response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
fn guess_content_type(filename: &str) -> &'static str {
if filename.ends_with(".mp4") { "video/mp4" }
else if filename.ends_with(".mov") { "video/quicktime" }
else if filename.ends_with(".png") { "image/png" }
else if filename.ends_with(".jpg") || filename.ends_with(".jpeg") { "image/jpeg" }
else if filename.ends_with(".webp") { "image/webp" }
else { "application/octet-stream" }
}
async fn upload_asset_bytes(
api_key: &crate::creds::beeble_api_key::BeebleApiKey,
filename: &str,
bytes: Vec<u8>,
) -> errors::AnyhowResult<crate::requests::create_upload_url::create_upload_url::CreateUploadUrlSuccess> {
use crate::requests::create_upload_url::create_upload_url::{
create_upload_url, CreateUploadUrlArgs,
};
// 1. Get presigned upload URL.
let upload = create_upload_url(CreateUploadUrlArgs {
api_key: api_key.clone(),
filename: filename.to_string(),
}).await.map_err(|e| anyhow::anyhow!("create_upload_url failed: {:?}", e))?;
println!("Upload ID: {}", upload.id);
println!("Upload URL: {}", upload.upload_url);
println!("Beeble URI: {}", upload.beeble_uri);
// 2. PUT file bytes to the presigned URL with correct Content-Type.
let content_type = guess_content_type(filename);
println!("Uploading {} bytes with Content-Type: {}", bytes.len(), content_type);
let client = reqwest::Client::new();
let put_response = client.put(&upload.upload_url)
.header("Content-Type", content_type)
.body(bytes)
.send()
.await?;
let put_status = put_response.status();
let put_body = put_response.text().await.unwrap_or_default();
println!("PUT upload status: {} body: {}", put_status, put_body);
assert!(put_status.is_success(), "PUT upload failed: {} body: {}", put_status, put_body);
Ok(upload)
}
#[tokio::test]
#[ignore] // manually test — requires real API key, downloads files, incurs costs
async fn test_end_to_end_video_generation() -> errors::AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
use test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
let api_key = get_test_api_key()?;
// 1. Download the source video.
println!("Downloading video...");
let video_bytes = download_bytes(ANGRY_SHIBA_VIDEO_URL).await?;
println!("Downloaded video: {} bytes", video_bytes.len());
// 2. Download the reference image.
println!("Downloading image...");
let image_bytes = download_bytes(FOREST_BACKDROP_IMAGE_URL).await?;
println!("Downloaded image: {} bytes", image_bytes.len());
// 3. Upload the video.
println!("Uploading video...");
let video_upload = upload_asset_bytes(&api_key, "shiba.mp4", video_bytes).await?;
// 4. Upload the reference image.
println!("Uploading image...");
let image_upload = upload_asset_bytes(&api_key, "forest.jpg", image_bytes).await?;
// 5. Start generation.
println!("Starting generation...");
let result = start_generation(StartGenerationArgs {
api_key,
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Video,
source_uri: video_upload.beeble_uri,
alpha_mode: BeebleAlphaMode::Auto,
prompt: Some("Place the dog in a forest setting".to_string()),
reference_image_uri: Some(image_upload.beeble_uri),
alpha_uri: None,
max_resolution: Some(720),
callback_url: None,
idempotency_key: None,
},
}).await.map_err(|e| anyhow::anyhow!("start_generation failed: {:?}", e))?;
println!("Job ID: {}", result.id);
println!("Status: {}", result.status);
println!("Progress: {:?}", result.progress);
assert!(result.id.starts_with("swx_"));
// NB: We don't poll to completion here — that would take too long for a test.
// Use get_job_status to poll manually if needed.
Ok(())
}
}
@@ -0,0 +1,11 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
use crate::creds::beeble_api_key::BeebleApiKey;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<BeebleApiKey> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/beeble_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(BeebleApiKey::new(api_key))
}
@@ -0,0 +1 @@
pub mod get_test_api_key;
@@ -0,0 +1,115 @@
use serde_derive::Deserialize;
/// Raw deserialized webhook payload from Beeble.
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookPayload {
pub id: String,
pub status: BeebleWebhookStatus,
pub output: Option<BeebleWebhookOutput>,
pub error: Option<String>,
pub completed_at: Option<String>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BeebleWebhookStatus {
Completed,
Failed,
/// Catch unknown variants gracefully.
#[serde(other)]
Unknown,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookOutput {
pub render: Option<String>,
pub source: Option<String>,
pub alpha: Option<String>,
}
pub fn parse_beeble_webhook(raw_body: &str) -> Result<BeebleWebhookPayload, serde_json::Error> {
serde_json::from_str(raw_body)
}
#[cfg(test)]
mod tests {
use super::*;
const SUCCESS_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": {
"render": "https://cdn.beeble.ai/render.mp4",
"source": "https://cdn.beeble.ai/source.mp4",
"alpha": "https://cdn.beeble.ai/alpha.mp4"
},
"completed_at": "2026-02-23T10:05:00Z"
}"#;
const FAILURE_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "failed",
"completed_at": "2026-02-23T10:05:00Z",
"error": "Processing failed"
}"#;
const UNKNOWN_STATUS_PAYLOAD: &str = r#"{
"id": "swx_xyz789",
"status": "processing",
"completed_at": null
}"#;
const NULL_OUTPUT_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": null,
"completed_at": "2026-02-23T10:05:00Z"
}"#;
mod parse_tests {
use super::*;
#[test]
fn parse_success_payload() {
let payload = parse_beeble_webhook(SUCCESS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.error.is_none());
let output = payload.output.unwrap();
assert_eq!(output.render.as_deref(), Some("https://cdn.beeble.ai/render.mp4"));
assert_eq!(output.source.as_deref(), Some("https://cdn.beeble.ai/source.mp4"));
assert_eq!(output.alpha.as_deref(), Some("https://cdn.beeble.ai/alpha.mp4"));
}
#[test]
fn parse_failure_payload() {
let payload = parse_beeble_webhook(FAILURE_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Failed);
assert_eq!(payload.error.as_deref(), Some("Processing failed"));
assert!(payload.output.is_none());
}
#[test]
fn parse_unknown_status() {
let payload = parse_beeble_webhook(UNKNOWN_STATUS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_xyz789");
assert_eq!(payload.status, BeebleWebhookStatus::Unknown);
}
#[test]
fn parse_null_output() {
let payload = parse_beeble_webhook(NULL_OUTPUT_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.output.is_none());
}
#[test]
fn parse_invalid_json() {
let result = parse_beeble_webhook("not valid json");
assert!(result.is_err());
}
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_payload;
@@ -0,0 +1,134 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
#[derive(Debug, Default)]
pub struct BeebleJobDetails {
pub job_token: InferenceJobToken,
pub status: JobStatusPlus,
pub external_third_party: InferenceJobExternalThirdParty,
pub external_third_party_id: String,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
struct RawJobRecord {
job_token: InferenceJobToken,
status: JobStatusPlus,
// NB: Nullable, but required to be filled for this query
external_third_party: Option<InferenceJobExternalThirdParty>,
// NB: Nullable, but required to be filled for this query
external_third_party_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id(beeble_id: &str, mysql_pool: &MySqlPool)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let mut connection = mysql_pool.acquire().await?;
get_inference_job_by_beeble_id_from_connection(beeble_id, &mut connection).await
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id_from_connection(beeble_id: &str, mysql_connection: &mut PoolConnection<MySql>)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let maybe_status = sqlx::query_as!(
RawJobRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,
jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
jobs.maybe_external_third_party_id as `external_third_party_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
jobs.created_at,
jobs.updated_at
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.maybe_external_third_party_id = ?
"#,
InferenceJobExternalThirdParty::Beeble.to_str(),
beeble_id
)
.fetch_one(&mut **mysql_connection)
.await;
let record = match maybe_status {
Ok(record) => record,
Err(err) => match err {
sqlx::Error::RowNotFound => return Ok(None),
_ => {
warn!("error querying job record: {:?}", err);
return Err(anyhow!("error querying job record: {:?}", err));
}
}
};
let record = raw_record_to_public_result(record)?;
Ok(Some(record))
}
fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<BeebleJobDetails> {
Ok(BeebleJobDetails {
job_token: record.job_token,
status: record.status,
external_third_party: record.external_third_party.ok_or_else(|| anyhow!("missing external_third_party"))?,
external_third_party_id: record.external_third_party_id.ok_or_else(|| anyhow!("missing external_third_party_id"))?,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
@@ -0,0 +1,168 @@
use anyhow::anyhow;
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
pub struct InsertGenericInferenceForBeebleWithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub uuid_idempotency_token: &'e str,
// NOTE: We'll generate this ahead of time so we can save it with billing info!
pub apriori_job_token: &'e InferenceJobToken,
/// The external primary key identifier for the job.
pub maybe_external_third_party_id: &'e str,
pub maybe_inference_args: Option<GenericInferenceArgs>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// Override the initial job status. Defaults to `Pending` when `None`.
/// Set to `Some(JobStatusPlus::CompleteFailure)` for mock/test failure jobs.
pub starting_job_status_override: Option<JobStatusPlus>,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
pub maybe_failure_reason: Option<&'e str>,
pub mysql_executor: E,
// TODO: Not sure if this works to tell the compiler we need the lifetime annotation.
// See: https://doc.rust-lang.org/std/marker/struct.PhantomData.html#unused-lifetime-parameters
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_beeble_queue_with_apriori_job_token<'e, 'c : 'e, E>(
args: InsertGenericInferenceForBeebleWithAprioriJobTokenArgs<'e, 'c, E>
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let serialized_args_payload = serde_json::ser::to_string(&args.maybe_inference_args)
.map_err(|_e| anyhow!("could not encode inference args"))?;
const JOB_TYPE: InferenceJobType = InferenceJobType::BeebleQueue;
const INFERENCE_CATEGORY: InferenceCategory = InferenceCategory::VideoGeneration;
const PRODUCT_CATEGORY: InferenceJobProductCategory = InferenceJobProductCategory::BeebleVideo;
const EXTERNAL_THIRD_PARTY: InferenceJobExternalThirdParty = InferenceJobExternalThirdParty::Beeble;
let status = args.starting_job_status_override.unwrap_or(JobStatusPlus::Pending);
let maybe_frontend_failure_category_str = args.maybe_frontend_failure_category
.map(|c| c.to_str());
let maybe_truncated_failure_reason = args.maybe_failure_reason
.map(|s| if s.len() > 255 { &s[..255] } else { s });
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
SET
token = ?,
uuid_idempotency_token = ?,
job_type = ?,
maybe_external_third_party = ?,
maybe_external_third_party_id = ?,
product_category = ?,
inference_category = ?,
maybe_model_type = NULL,
maybe_model_token = NULL,
maybe_input_source_token = NULL,
maybe_input_source_token_type = NULL,
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = ?,
maybe_raw_inference_text = NULL,
maybe_inference_args = ?,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
priority_level = 0,
is_keepalive_required = FALSE,
max_duration_seconds = 0,
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
frontend_failure_category = ?,
failure_reason = ?,
status = ?
"#,
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
JOB_TYPE.to_str(),
EXTERNAL_THIRD_PARTY.to_str(),
args.maybe_external_third_party_id,
PRODUCT_CATEGORY.to_str(),
INFERENCE_CATEGORY.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
serialized_args_payload,
args.maybe_creator_user_token.map(|t| t.to_string()),
args.maybe_avt_token.map(|t| t.to_string()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
maybe_frontend_failure_category_str,
maybe_truncated_failure_reason,
status.to_str(),
);
let query_result = query.execute(args.mysql_executor)
.await;
let record_id = match query_result {
Err(err) => return Err(DatabaseQueryError::from(err)),
Ok(res) => res.last_insert_id(),
};
info!("Insert generic inference job for Beeble queue: {} with record ID {}", args.apriori_job_token, record_id);
Ok(args.apriori_job_token.clone())
}
@@ -0,0 +1,2 @@
pub mod get_inference_job_by_beeble_id;
pub mod insert_generic_inference_job_for_beeble_queue_with_apriori_job_token;
@@ -1,3 +1,4 @@
pub mod beeble;
pub mod fal;
pub mod job;
pub mod moderation;
@@ -22,6 +22,7 @@ pub enum DebugLogType {
FalRequest,
KinoviRequest,
FalWebhook,
BeebleWebhook,
}
impl_enum_display_and_debug_using_to_str!(DebugLogType);
@@ -35,6 +36,7 @@ impl DebugLogType {
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::FalWebhook => "fal_webhook",
Self::BeebleWebhook => "beeble_webhook",
}
}
@@ -44,6 +46,7 @@ impl DebugLogType {
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"fal_webhook" => Ok(Self::FalWebhook),
"beeble_webhook" => Ok(Self::BeebleWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -54,6 +57,7 @@ impl DebugLogType {
Self::FalRequest,
Self::KinoviRequest,
Self::FalWebhook,
Self::BeebleWebhook,
])
}
}
@@ -74,6 +78,7 @@ mod tests {
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook");
}
#[test]
@@ -82,6 +87,7 @@ mod tests {
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook");
}
#[test]
@@ -90,6 +96,7 @@ mod tests {
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook);
}
#[test]
@@ -106,11 +113,12 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook));
assert_eq!(variants.pop_first(), None);
}
}
@@ -12,6 +12,10 @@ use strum::EnumIter;
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum InferenceJobExternalThirdParty {
/// Beeble jobs
#[serde(rename = "beeble")]
Beeble,
/// Fal jobs
#[serde(rename = "fal")]
#[default]
@@ -34,6 +38,7 @@ impl_mysql_enum_coders!(InferenceJobExternalThirdParty);
impl InferenceJobExternalThirdParty {
pub fn to_str(&self) -> &'static str {
match self {
Self::Beeble => "beeble",
Self::Fal => "fal",
Self::Seedance2Pro => "seedance2pro",
Self::Worldlabs => "worldlabs",
@@ -42,6 +47,7 @@ impl InferenceJobExternalThirdParty {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"beeble" => Ok(Self::Beeble),
"fal" => Ok(Self::Fal),
"seedance2pro" => Ok(Self::Seedance2Pro),
"worldlabs" => Ok(Self::Worldlabs),
@@ -53,6 +59,7 @@ impl InferenceJobExternalThirdParty {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Beeble,
Self::Fal,
Self::Seedance2Pro,
Self::Worldlabs,
@@ -70,6 +77,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobExternalThirdParty::Beeble, "beeble");
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs");
@@ -77,6 +85,7 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobExternalThirdParty::Beeble.to_str(), "beeble");
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs");
@@ -84,6 +93,7 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobExternalThirdParty::from_str("beeble").unwrap(), InferenceJobExternalThirdParty::Beeble);
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs);
@@ -92,7 +102,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 3;
const EXPECTED_COUNT : usize = 4;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -30,6 +30,11 @@ pub enum InferenceJobProductCategory {
FalObject,
FalBgRemoval,
// =============== BEEBLE ===============
#[serde(rename = "beeble_video")]
BeebleVideo,
// =============== SEEDANCE 2 PRO ===============
#[serde(rename = "seedance2pro_video")]
@@ -139,6 +144,7 @@ impl InferenceJobProductCategory {
Self::FalVideo => "fal_video",
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsSplat => "worldlabs_splat",
@@ -174,6 +180,7 @@ impl InferenceJobProductCategory {
"fal_video" => Ok(Self::FalVideo),
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
@@ -212,6 +219,7 @@ impl InferenceJobProductCategory {
Self::FalVideo,
Self::FalObject,
Self::FalBgRemoval,
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
Self::WorldlabsSplat,
@@ -256,6 +264,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
@@ -290,6 +299,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
@@ -323,6 +333,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
@@ -352,7 +363,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 30;
const EXPECTED_COUNT : usize = 31;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -27,6 +27,10 @@ pub enum InferenceJobType {
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// Beeble jobs. We poll for results.
#[serde(rename = "beeble_queue")]
BeebleQueue,
/// World Labs jobs. We poll for results.
#[serde(rename = "worldlabs_queue")]
WorldlabsQueue,
@@ -122,6 +126,7 @@ impl InferenceJobType {
pub fn to_str(&self) -> &'static str {
match self {
Self::FalQueue => "fal_queue",
Self::BeebleQueue => "beeble_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsQueue => "worldlabs_queue",
@@ -151,6 +156,7 @@ impl InferenceJobType {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"fal_queue" => Ok(Self::FalQueue),
"beeble_queue" => Ok(Self::BeebleQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"worldlabs_queue" => Ok(Self::WorldlabsQueue),
@@ -183,6 +189,7 @@ impl InferenceJobType {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::FalQueue,
Self::BeebleQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
Self::WorldlabsQueue,
@@ -226,6 +233,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobType::WorldlabsQueue, "worldlabs_queue");
@@ -254,6 +262,7 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobType::WorldlabsQueue.to_str(), "worldlabs_queue");
@@ -282,6 +291,7 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
assert_eq!(InferenceJobType::from_str("worldlabs_queue").unwrap(), InferenceJobType::WorldlabsQueue);
@@ -310,7 +320,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 24;
const EXPECTED_COUNT : usize = 25;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -69,6 +69,9 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "video_gen")]
VideoGeneration,
#[serde(rename = "video_edit")]
VideoEdit,
#[serde(rename = "world_gen")]
WorldGeneration,
@@ -105,6 +108,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap => "mocap",
Self::ImageGeneration => "image_gen",
Self::VideoGeneration => "video_gen",
Self::VideoEdit => "video_edit",
Self::WorldGeneration => "world_gen",
Self::VideoFilter => "video_filter",
Self::Workflow => "workflow",
@@ -126,6 +130,7 @@ impl MediaFileOriginProductCategory {
"mocap" => Ok(Self::Mocap),
"image_gen" => Ok(Self::ImageGeneration),
"video_gen" => Ok(Self::VideoGeneration),
"video_edit" => Ok(Self::VideoEdit),
"world_gen" => Ok(Self::WorldGeneration),
"video_filter" => Ok(Self::VideoFilter),
"workflow" => Ok(Self::Workflow),
@@ -150,6 +155,7 @@ impl MediaFileOriginProductCategory {
Self::Mocap,
Self::ImageGeneration,
Self::VideoGeneration,
Self::VideoEdit,
Self::WorldGeneration,
Self::VideoFilter,
Self::Workflow,
@@ -180,6 +186,7 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap");
assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen");
assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen");
assert_serialization(MediaFileOriginProductCategory::VideoEdit, "video_edit");
assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen");
assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter");
assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow");
@@ -200,6 +207,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap");
assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen");
assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen");
assert_eq!(MediaFileOriginProductCategory::VideoEdit.to_str(), "video_edit");
assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen");
assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter");
assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow");
@@ -220,6 +228,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap);
assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_edit").unwrap(), MediaFileOriginProductCategory::VideoEdit);
assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter);
assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow);
@@ -228,7 +237,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 16);
assert_eq!(variants.len(), 17);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
@@ -242,6 +251,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoEdit));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow));
@@ -144,6 +144,8 @@ pub enum CommonModelType {
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "switch_x")]
SwitchX,
// 3D Object generation models
#[serde(rename = "hunyuan_3d_2p0")]
@@ -224,6 +226,7 @@ impl CommonModelType {
Self::Veo3Fast => "veo_3_fast",
Self::Veo3p1 => "veo_3p1",
Self::Veo3p1Fast => "veo_3p1_fast",
Self::SwitchX => "switch_x",
// 3D Object generation models
Self::Hunyuan3d2_0 => "hunyuan_3d_2p0",
@@ -293,6 +296,7 @@ impl CommonModelType {
"veo_3_fast" => Ok(Self::Veo3Fast),
"veo_3p1" => Ok(Self::Veo3p1),
"veo_3p1_fast" => Ok(Self::Veo3p1Fast),
"switch_x" => Ok(Self::SwitchX),
// 3D Object generation models
"hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0),
@@ -366,6 +370,7 @@ impl CommonModelType {
Self::Veo3Fast,
Self::Veo3p1,
Self::Veo3p1Fast,
Self::SwitchX,
// 3D Object generation models
Self::Hunyuan3d2_0,
@@ -436,6 +441,7 @@ impl CommonModelType {
Self::Veo3Fast => CommonModelClass::Video,
Self::Veo3p1 => CommonModelClass::Video,
Self::Veo3p1Fast => CommonModelClass::Video,
Self::SwitchX => CommonModelClass::Video,
// 3D Object generation models (mesh)
Self::Hunyuan3d2_0 => CommonModelClass::DimensionalMesh,
@@ -515,6 +521,7 @@ mod tests {
assert_serialization(CommonModelType::Veo3Fast, "veo_3_fast");
assert_serialization(CommonModelType::Veo3p1, "veo_3p1");
assert_serialization(CommonModelType::Veo3p1Fast, "veo_3p1_fast");
assert_serialization(CommonModelType::SwitchX, "switch_x");
// 3D Object generation models
assert_serialization(CommonModelType::Hunyuan3d2_0, "hunyuan_3d_2p0");
assert_serialization(CommonModelType::Hunyuan3d2_1, "hunyuan_3d_2p1");
@@ -581,6 +588,7 @@ mod tests {
assert_eq!(CommonModelType::Veo3Fast.to_str(), "veo_3_fast");
assert_eq!(CommonModelType::Veo3p1.to_str(), "veo_3p1");
assert_eq!(CommonModelType::Veo3p1Fast.to_str(), "veo_3p1_fast");
assert_eq!(CommonModelType::SwitchX.to_str(), "switch_x");
// 3D Object generation models
assert_eq!(CommonModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2p0");
@@ -647,7 +655,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("veo_3_fast").unwrap(), CommonModelType::Veo3Fast);
assert_eq!(CommonModelType::from_str("veo_3p1").unwrap(), CommonModelType::Veo3p1);
assert_eq!(CommonModelType::from_str("veo_3p1_fast").unwrap(), CommonModelType::Veo3p1Fast);
assert_eq!(CommonModelType::from_str("switch_x").unwrap(), CommonModelType::SwitchX);
// 3D Object generation models
assert_eq!(CommonModelType::from_str("hunyuan_3d_2p0").unwrap(), CommonModelType::Hunyuan3d2_0);
assert_eq!(CommonModelType::from_str("hunyuan_3d_2p1").unwrap(), CommonModelType::Hunyuan3d2_1);
@@ -660,7 +669,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 57);
assert_eq!(variants.len(), 58);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -715,6 +724,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::SwitchX));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_1));
@@ -50,6 +50,7 @@ datetimes = { path = "../../../lib/data/datetimes" }
easyenv.workspace = true
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
enums.workspace = true
beeble_client.workspace = true
error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
@@ -51,6 +51,8 @@ FAL_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://halide.tailce84f.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://s1.pancake-chimaera.ts.net/api/fal/webhook"
BEEBLE_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/beeble"
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL="https://getartcraft.com/checkout_success"
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL="https://getartcraft.com/checkout_cancel"
STRIPE_ARTCRAFT_PORTAL_RETURN_URL="https://getartcraft.com/portal_closed"
@@ -85,6 +85,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::*;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
@@ -365,6 +366,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::generate::video::image::generate_kling_2_1_master_video_handler::generate_kling_2_1_master_video_handler,
crate::http_server::endpoints::generate::video::image::generate_kling_2_1_pro_video_handler::generate_kling_2_1_pro_video_handler,
crate::http_server::endpoints::generate::video::image::generate_seedance_1_0_lite_image_to_video_handler::generate_seedance_1_0_lite_image_to_video_handler,
crate::http_server::endpoints::generate::video::edit::beeble_switchx_edit_video_gen_handler::beeble_switchx_edit_video_gen_handler,
crate::http_server::endpoints::generate::video::image::generate_veo_2_image_to_video_handler::generate_veo_2_image_to_video_handler,
crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::upload_snapshot_media_file_handler,
crate::http_server::endpoints::inference_job::delete::dismiss_finished_session_jobs_handler::dismiss_finished_session_jobs_handler,
@@ -860,6 +862,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GenerateSeedance10LiteImageToVideoResponse,
GenerateSeedance10LiteResolution,
GenerateVeo2AspectRatio,
BeebleSwitchXEditVideoRequest,
BeebleSwitchXEditVideoResponse,
GenerateVeo2Duration,
GenerateVeo2ImageToVideoRequest,
GenerateVeo2ImageToVideoResponse,
@@ -0,0 +1,345 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse,
};
use beeble_client::error::beeble_error::BeebleError;
use beeble_client::error::beeble_specific_api_error::BeebleSpecificApiError;
use beeble_client::requests::create_upload_url::upload_bytes::upload_bytes_to_beeble;
use beeble_client::requests::start_generation::start_generation::{
start_generation, BeebleAlphaMode, BeebleGenerationType, StartGenerationArgs,
StartGenerationRequest,
};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::generic_inference::beeble::insert_generic_inference_job_for_beeble_queue_with_apriori_job_token::{
insert_generic_inference_job_for_beeble_queue_with_apriori_job_token,
InsertGenericInferenceForBeebleWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
/// Beeble SwitchX Video Edit
#[utoipa::path(
post,
tag = "Generate Videos",
path = "/v1/generate/video/edit/beeble_switchx",
request_body = BeebleSwitchXEditVideoRequest,
responses(
(status = 200, description = "Success", body = BeebleSwitchXEditVideoResponse),
(status = 400, description = "Bad input"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Payment required"),
(status = 500, description = "Server error"),
),
)]
pub async fn beeble_switchx_edit_video_gen_handler(
http_request: HttpRequest,
request: Json<BeebleSwitchXEditVideoRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BeebleSwitchXEditVideoResponse>, AdvancedCommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
// ==================== SESSION ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
None => return Err(AdvancedCommonWebError::NotAuthorized),
};
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== VALIDATE INPUT ==================== //
let source_video_media_token = request.source_video_media_token.as_ref()
.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage("source_video_media_token is required".to_string())
})?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
AdvancedCommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// ==================== LOOKUP MEDIA FILES (BATCH) ==================== //
let mut tokens_to_lookup: Vec<MediaFileToken> = vec![source_video_media_token.clone()];
if let Some(ref_token) = &request.reference_image_media_token {
tokens_to_lookup.push(ref_token.clone());
}
let cdn_url_map = lookup_image_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&tokens_to_lookup,
).await.map_err(|err| {
warn!("Error looking up media files: {:?}", err);
AdvancedCommonWebError::from(err)
})?;
let source_video_cdn_url = cdn_url_map.get(source_video_media_token)
.ok_or_else(|| {
warn!("Source video media file not found: {:?}", source_video_media_token);
AdvancedCommonWebError::NotFound
})?
.clone();
let maybe_reference_image_cdn_url = request.reference_image_media_token.as_ref()
.map(|ref_token| {
cdn_url_map.get(ref_token)
.cloned()
.ok_or_else(|| {
warn!("Reference image media file not found: {:?}", ref_token);
AdvancedCommonWebError::NotFound
})
})
.transpose()?;
// ==================== DOWNLOAD & UPLOAD TO BEEBLE ==================== //
let beeble_api_key = &server_state.beeble.api_key;
// Download + upload source video
info!("Downloading source video from CDN: {}", source_video_cdn_url);
let video_bytes = http_download_url_to_bytes(&source_video_cdn_url).await
.map_err(|err| {
error!("Failed to download source video: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Downloaded source video: {} bytes", video_bytes.len());
let video_extension = std::path::Path::new(&source_video_cdn_url)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp4");
let video_filename = format!("{}.{}", source_video_media_token.as_str(), video_extension);
let video_content_type = match video_extension {
"mp4" => "video/mp4",
"mov" => "video/quicktime",
_ => "application/octet-stream",
};
let video_upload = upload_bytes_to_beeble(
beeble_api_key, &video_filename, video_content_type, video_bytes.to_vec(),
).await.map_err(|err| {
error!("Beeble video upload failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Source video uploaded to Beeble: {}", video_upload.beeble_uri);
// Download + upload reference image (if provided)
let maybe_reference_beeble_uri = if let Some(ref_cdn_url) = &maybe_reference_image_cdn_url {
info!("Downloading reference image from CDN: {}", ref_cdn_url);
let image_bytes = http_download_url_to_bytes(ref_cdn_url).await
.map_err(|err| {
error!("Failed to download reference image: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Downloaded reference image: {} bytes", image_bytes.len());
let ref_token = request.reference_image_media_token.as_ref().unwrap();
let image_filename = format!("{}.jpg", ref_token.as_str());
let image_upload = upload_bytes_to_beeble(
beeble_api_key, &image_filename, "image/jpeg", image_bytes.to_vec(),
).await.map_err(|err| {
error!("Beeble image upload failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!("Reference image uploaded to Beeble: {}", image_upload.beeble_uri);
Some(image_upload.beeble_uri)
} else {
None
};
// ==================== CALL BEEBLE GENERATE ==================== //
let apriori_job_token = InferenceJobToken::generate();
let beeble_result = start_generation(StartGenerationArgs {
api_key: beeble_api_key.clone(),
request: StartGenerationRequest {
generation_type: BeebleGenerationType::Video,
source_uri: video_upload.beeble_uri,
alpha_mode: BeebleAlphaMode::Auto,
prompt: request.prompt.clone(),
reference_image_uri: maybe_reference_beeble_uri,
alpha_uri: None,
max_resolution: Some(1080),
callback_url: Some(server_state.beeble.webhook_url.clone()),
idempotency_key: Some(request.uuid_idempotency_token.clone()),
},
}).await.map_err(|err| {
warn!("Beeble start_generation failed: {:?}", err);
match err {
BeebleError::ApiSpecific(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames,
detected_frames,
ref message,
}) => {
AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Video has too many frames: {} (max: {}); {}",
detected_frames, max_frames, message
))
},
_ => {
AdvancedCommonWebError::from_error(err)
}
}
})?;
let external_job_id = &beeble_result.id;
info!("Beeble job started: id={}, status={}", external_job_id, beeble_result.status);
// ==================== DB TRANSACTION ==================== //
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// -- Prompt --
let prompt_token = match insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: Some(user_token),
maybe_model_type: Some(CommonModelType::SwitchX),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Edit),
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None
}
};
// -- Prompt context items --
if let Some(token) = prompt_token.as_ref() {
let mut context_items: Vec<PromptContextItem> = Vec::new();
context_items.push(PromptContextItem {
media_token: source_video_media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidRef,
});
if let Some(ref_token) = &request.reference_image_media_token {
context_items.push(PromptContextItem {
media_token: ref_token.clone(),
context_semantic_type: PromptContextSemanticType::Imgref,
});
}
if !context_items.is_empty() {
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
prompt_token: token.clone(),
items: context_items,
transaction: &mut transaction,
}).await {
warn!("Error inserting batch prompt context items: {:?}", err);
}
}
}
// -- Inference job --
let db_result = insert_generic_inference_job_for_beeble_queue_with_apriori_job_token(
InsertGenericInferenceForBeebleWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
uuid_idempotency_token: &request.uuid_idempotency_token,
maybe_external_third_party_id: external_job_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: None,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
).await;
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
error!("Error inserting inference job: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
}
};
transaction.commit().await.map_err(|err| {
error!("Error committing transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
Ok(Json(BeebleSwitchXEditVideoResponse {
success: true,
inference_job_token: job_token,
}))
}
@@ -0,0 +1 @@
pub mod beeble_switchx_edit_video_gen_handler;
@@ -1,2 +1,3 @@
pub mod edit;
pub mod image;
pub mod multi_function;
@@ -0,0 +1,427 @@
use std::io::Write;
use std::sync::Arc;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tempfile::NamedTempFile;
use beeble_client::webhook_api::beeble_webhook_payload::{
parse_beeble_webhook, BeebleWebhookPayload, BeebleWebhookStatus,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::beeble::get_inference_job_by_beeble_id::{
get_inference_job_by_beeble_id_from_connection, BeebleJobDetails,
};
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{
mark_fal_generic_inference_job_successfully_done, MarkJobArgs,
};
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{
mark_job_failed_by_token_from_connection, MarkJobFailedByTokenFromConnectionArgs,
};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
const VIDEO_BUCKET_PREFIX: Option<&str> = Some("artcraft_");
// =============== Handler ===============
pub async fn beeble_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
// Step 1: Parse bytes into a UTF-8 string and log it.
let raw_body = String::from_utf8(request_body_bytes.to_vec())
.map_err(|err| {
error!("Beeble webhook: could not decode request body to UTF-8: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
AdvancedCommonWebError::from_error(err)
})?;
info!("Received Beeble webhook body: {}", raw_body);
// Step 2: Parse into BeebleWebhookPayload.
let payload = parse_beeble_webhook(&raw_body)
.map_err(|err| {
error!("Beeble webhook: could not parse webhook payload: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
AdvancedCommonWebError::from_error(err)
})?;
let job_id = payload.id.as_str();
info!("Beeble webhook job_id: {} (status: {:?})", job_id, payload.status);
// Step 3: Acquire a MySQL connection.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Step 4: Look up the job by external third-party ID.
let job = match get_inference_job_by_beeble_id_from_connection(job_id, &mut mysql_connection).await {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by Beeble job_id: {}", job_id);
return Err(AdvancedCommonWebError::NotFound);
}
Err(err) => {
error!("Error querying job record for Beeble job_id {}: {:?}", job_id, err);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
};
info!("Beeble webhook job record for job_id {}: {:?}", job_id, job);
// Insert debug log for the webhook payload.
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::BeebleWebhook,
message: &raw_body,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Beeble webhook debug log: {:?}", err);
}
}
// Step 5: Branch on status.
let result = match payload.status {
BeebleWebhookStatus::Completed => {
handle_completed(&server_state, &mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Failed => {
handle_failed(&mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Unknown => {
warn!("Beeble webhook received unknown status for job_id {}", job_id);
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Beeble webhook received unknown status for job_id {}", job_id),
))
}
};
if let Err(ref err) = result {
if err.is_server_error() {
error!("Beeble webhook error for job_id {}: {:?}", job_id, err);
let description = format!(
"Beeble webhook failed for job_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
job_id, err, raw_body,
);
let mut builder = if let Some(cause_arc) = err.clone_cause_arc() {
NotificationDetailsBuilder::from_error(cause_arc)
.set_title(format!("Beeble webhook processing failed (job_id: {})", job_id))
} else {
NotificationDetailsBuilder::from_title(
format!("Beeble webhook processing failed (job_id: {})", job_id)
)
};
builder = builder
.set_description(Some(description))
.set_third_party_id(Some(job_id.to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()));
if let Err(pager_err) = server_state.pager.enqueue_page(builder.build()) {
error!("Failed to enqueue Beeble webhook pager alert: {:?}", pager_err);
}
}
}
result
}
// =============== Private helpers ===============
async fn handle_completed(
server_state: &ServerState,
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let output = payload.output.as_ref().ok_or_else(|| {
warn!("Beeble webhook completed but no output for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but output is missing")
})?;
let render_url = output.render.as_deref().ok_or_else(|| {
warn!("Beeble webhook completed but no render URL for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but render URL is missing")
})?;
info!("Downloading Beeble render video from {} for job_id {}", render_url, job_id);
// Download and process the primary render video.
let render_media_token = download_and_upload_video(render_url, job, server_state).await?;
info!("Render video uploaded with token {} for job_id {}", render_media_token, job_id);
// Download and process the alpha video if present.
if let Some(ref alpha_url) = output.alpha {
info!("Downloading Beeble alpha video from {} for job_id {}", alpha_url, job_id);
match download_and_upload_video(alpha_url, job, server_state).await {
Ok(alpha_token) => {
info!("Alpha video uploaded with token {} for job_id {}", alpha_token, job_id);
}
Err(err) => {
// Fail open for alpha — the render is the primary artifact.
warn!("Failed to process alpha video for job_id {}: {:?}", job_id, err);
}
}
}
// Mark the job as successfully done with the render video's media token.
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
job_token: &job.job_token,
media_file_token: &render_media_token,
maybe_batch_token: None,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done for job_id {}: {:?}", job_id, err);
AdvancedCommonWebError::from_anyhow_error(err)
})?;
info!("Job {} marked as successfully done for Beeble job_id {}", job.job_token, job_id);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn handle_failed(
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let public_failure_reason = payload.error
.as_deref()
.unwrap_or("Unknown Beeble error")
.to_string();
let internal_failure_reason = format!(
"Beeble job_id={}, error={:?}",
job_id,
payload.error,
);
info!(
"Marking job {} as failed for Beeble job_id {}. Reason: {}",
job.job_token.as_str(),
job_id,
public_failure_reason,
);
if let Err(err) = mark_job_failed_by_token_from_connection(MarkJobFailedByTokenFromConnectionArgs {
mysql_connection,
job_token: &job.job_token,
maybe_public_failure_reason: Some(&public_failure_reason),
internal_debugging_failure_reason: &internal_failure_reason,
maybe_frontend_failure_category: Some(FrontendFailureCategory::GenerationFailed),
}).await {
error!(
"Error marking job {} as failed for Beeble job_id {}: {:?}",
job.job_token.as_str(),
job_id,
err,
);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
info!(
"Job {} marked as failed for Beeble job_id {}.",
job.job_token.as_str(),
job_id,
);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn download_and_upload_video(
video_url: &str,
job: &BeebleJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, AdvancedCommonWebError> {
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
video_url,
);
file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video on retry from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
}
// Resolve mime type from magic bytes, with no fallback content_type from Beeble.
let metadata = resolve_file_metadata(&file_bytes, None)
.ok_or_else(|| {
warn!(
"Could not determine file type for Beeble video (bytes: {})",
file_bytes.len(),
);
AdvancedCommonWebError::server_error_with_message(
&format!("Could not determine file type for Beeble video (bytes: {})", file_bytes.len()))
})?;
info!("File type: {}, extension: {:?}, source: {:?}",
metadata.mime_type, metadata.file_extension, metadata.source);
let mime_type = metadata.mime_type.as_str();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported media file type: {}", mime_type);
AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported media file type: {}", mime_type))
})?;
let extension_with_period = metadata.file_extension.extension_with_period();
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)
.map_err(|e| {
warn!("Failed to hash video bytes: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let mut maybe_duration_millis = None;
let mut maybe_frame_width = None;
let mut maybe_frame_height = None;
if let Ok(mut file) = NamedTempFile::new() {
let _r = file.write_all(&file_bytes);
if let Ok(video_info) = ffprobe_get_info(&file.path()) {
maybe_duration_millis = video_info.duration
.as_ref()
.map(|duration| duration.millis as u64);
maybe_frame_width = video_info.dimensions
.as_ref()
.map(|dims| dims.width as u32);
maybe_frame_height = video_info.dimensions
.as_ref()
.map(|dims| dims.height as u32);
}
}
let public_upload_path = MediaFileBucketPath::generate_new(VIDEO_BUCKET_PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
mime_type)
.await
.map_err(|e| {
warn!("Failed to upload video to bucket: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Video)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoEdit)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_frame_width(maybe_frame_width)
.maybe_frame_height(maybe_frame_height)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&file_hash)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Failed to insert video media file record: {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
info!("Video media file uploaded with token: {}", media_token);
// let thumbnail_task_result =
// ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
// .with_bucket(server_state.public_bucket_client.bucket_name().as_str())
// .with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
// .with_output_suffix("thumb")
// .with_event_id(&media_token.to_string())
// .send_all()
// .await;
// if let Err(err) = thumbnail_task_result {
// // Fail open
// error!("Failed to create some/all thumbnail tasks: {:?}", err);
// }
Ok(media_token)
}
/// Send a pager alert for early parse failures (before we have a job_id).
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
server_state: &ServerState,
http_request: &HttpRequest,
context: &str,
err: &E,
maybe_raw_body: Option<&str>,
) {
let description = match maybe_raw_body {
Some(body) => format!("Error: {:?}\n\nBeeble Webhook JSON Payload: {}", err, body),
None => format!("Beeble Error: {:?}", err),
};
let notification = NotificationDetailsBuilder::from_title(
format!("Beeble webhook parse failure: {}", context))
.set_description(Some(description))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue Beeble webhook parse error alert: {:?}", pager_err);
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_handler;
@@ -1,8 +1,8 @@
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::state::server_state::ServerState;
use actix_web::web::Bytes;
use actix_web::web::Json;
@@ -0,0 +1,3 @@
pub mod fal_webhook_handler;
pub(super) mod process_failure;
pub(super) mod process_success;
@@ -4,4 +4,4 @@ mod process_images_payload;
mod process_model_glb_payload;
mod process_model_mesh_payload;
mod process_video_payload;
mod resolve_file_metadata;
pub(in crate::http_server::endpoints::webhooks) mod resolve_file_metadata;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,3 +1,2 @@
pub mod fal_webhook_handler;
pub (super) mod process_failure;
pub (super) mod process_success;
pub mod beeble;
pub mod fal;
@@ -1,3 +1,4 @@
use crate::http_server::endpoints::generate::video::edit::beeble_switchx_edit_video_gen_handler::beeble_switchx_edit_video_gen_handler;
use crate::http_server::endpoints::generate::cost_estimate::image::estimate_image_cost_handler::estimate_image_cost_handler;
use crate::http_server::endpoints::generate::cost_estimate::splat::estimate_splat_cost_handler::estimate_splat_cost_handler;
use crate::http_server::endpoints::generate::cost_estimate::video::estimate_video_cost_handler::estimate_video_cost_handler;
@@ -218,6 +219,12 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/edit")
.service(web::resource("/beeble_switchx")
.route(web::post().to(beeble_switchx_edit_video_gen_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/kling_1.6_pro_image_to_video")
.route(web::post().to(generate_kling_1_6_pro_video_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -1,4 +1,5 @@
use crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler;
use crate::http_server::endpoints::webhooks::beeble::beeble_webhook_handler::beeble_webhook_handler;
use crate::http_server::endpoints::webhooks::fal::fal_webhook_handler::fal_webhook_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
@@ -20,5 +21,9 @@ where
.route(web::post().to(fal_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/beeble")
.route(web::post().to(beeble_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -80,7 +80,7 @@ use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator
use crate::startup::build_pager::build_pager;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::state::server_state::{BeebleData, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
@@ -475,6 +475,12 @@ async fn main() -> AnyhowResult<()> {
sort_key_crypto,
opaque_cursors: opaque_cursor_encoder,
static_api_token_set,
beeble: BeebleData {
api_key: beeble_client::creds::beeble_api_key::BeebleApiKey::new(
easyenv::get_env_string_required("BEEBLE_API_KEY")?,
),
webhook_url: easyenv::get_env_string_required("BEEBLE_WEBHOOK_URL")?,
},
fal: FalData {
api_key: fal_api_key,
webhook_url: fal_webhook_url,
@@ -25,6 +25,7 @@ use billing_component::stripe::stripe_config::StripeConfig;
use chrono::{DateTime, Utc};
use cloud_storage::bucket_client::BucketClient;
use elasticsearch::Elasticsearch;
use beeble_client::creds::beeble_api_key::BeebleApiKey;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
@@ -95,6 +96,8 @@ pub struct ServerState {
pub fal: FalData,
pub beeble: BeebleData,
pub seedance2pro: Seedance2ProData,
pub openai: OpenAiData,
@@ -321,6 +324,13 @@ pub struct FalData {
pub webhook_url: String,
}
/// Beeble SwitchX integration
#[derive(Clone)]
pub struct BeebleData {
pub api_key: BeebleApiKey,
pub webhook_url: String,
}
/// Seedance 2 Pro integration
#[derive(Clone)]
pub struct Seedance2ProData {
+2 -1
View File
@@ -1,3 +1,4 @@
pub mod character_tokens;
pub mod image_media_tokens;
pub mod image_urls;
pub mod image_urls;
pub mod video_urls;
@@ -0,0 +1,5 @@
// media token: m_fz9r14yhfe05ffg9wqda5291p7mp75
pub const ANGRY_SHIBA_VIDEO_URL : &str = "https://cdn-2.fakeyou.com/media/q/0/1/e/9/q01e9xsye0scamh5mrskr2emsv401x14/artcraft_q01e9xsye0scamh5mrskr2emsv401x14.mp4";
@@ -343,10 +343,10 @@
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
}
]
}
@@ -1,3 +1,7 @@
/background-change /index.html 200
/background-change/ /index.html 200
/background-change/* /index.html 200
/checkout_cancel /index.html 200
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
@@ -13,6 +13,7 @@ import Navbar from "../components/navbar";
import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import CreateVFX from "../pages/create-vfx";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
@@ -54,6 +55,7 @@ export function App() {
<Route path="/landing3" element={<Landing3 />} />
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
@@ -42,6 +42,7 @@ const NAV_ITEMS: NavEntry[] = [
{ name: "Home", href: "/" },
{ name: "Image", href: "/create-image" },
{ name: "Video", href: "/create-video" },
{ name: "BG Change", href: "/background-change" },
{
name: "Resources",
children: [
@@ -72,6 +73,71 @@ function isEntryActive(pathname: string, entry: NavEntry): boolean {
return isPathActive(pathname, entry.href);
}
function CreditsChip({
credits,
align,
onBuyCredits,
onUpgrade,
}: {
credits: number;
align: "center" | "end";
onBuyCredits: () => void;
onUpgrade: () => void;
}) {
return (
<PopoverMenu
position="bottom"
align={align}
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary text-[11px]" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
onBuyCredits();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<FontAwesomeIcon icon={faCoins} className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
onUpgrade();
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
);
}
async function fetchCredits(): Promise<number | null> {
try {
const api = new CreditsApi();
@@ -281,62 +347,12 @@ export default function Navbar() {
</Link>
{credits !== null && (
<PopoverMenu
position="bottom"
<CreditsChip
credits={credits}
align="center"
triggerIcon={
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[11px]"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
setCreditsModalOpen(true);
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-medium text-white tracking-tight">
<FontAwesomeIcon
icon={faCoins}
className="text-xl text-primary"
/>
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{hasPaidPlan === false && (
@@ -488,8 +504,16 @@ export default function Navbar() {
</div>
)}
{/* Mobile: hamburger + task queue */}
<div className="flex items-center gap-2 lg:hidden">
{/* Mobile: credits + task queue + hamburger */}
<div className="flex items-center gap-1.5 lg:hidden">
{user && credits !== null && (
<CreditsChip
credits={credits}
align="end"
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{user && <TaskQueue />}
<DisclosureButton className="flex h-8 w-8 items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors">
<span className="sr-only">Open main menu</span>
@@ -640,15 +664,6 @@ export default function Navbar() {
/>
Discord
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-1.5 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()}
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
@@ -23,7 +23,7 @@ import { Button } from "@storyteller/ui-button";
import {
getProviderDisplayName,
getModelDisplayName,
ALL_MODELS_LIST,
findModelByKey,
} from "@storyteller/model-list";
import { CloseButton } from "@storyteller/ui-close-button";
import dayjs from "dayjs";
@@ -157,10 +157,10 @@ const PromptLine = ({
style={
marqueePlaying
? {
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
: undefined
}
>
@@ -468,9 +468,7 @@ function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cached?.maybe_positive_prompt ||
job.request.maybe_raw_inference_text ||
""
cached?.maybe_positive_prompt || job.request.maybe_raw_inference_text || ""
);
}
@@ -483,8 +481,7 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
const modelTypeStr =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
const providerKey =
cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
const providerKey = cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
let kind: string | undefined;
if (taskTypeStr.includes("video")) {
@@ -532,18 +529,24 @@ function jobsToInProgress(
const parts = formatTitleParts(j, promptsMap);
const isVideo =
j.request.inference_category?.toLowerCase().includes("video") ?? false;
const modelType = j.request.maybe_model_type;
// Look up per-model estimated duration, cache per task
const promptToken = j.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? j.request.maybe_model_type;
// Look up per-model estimated duration from the model list, cache per
// task. Cache only once `modelType` is resolved so a transient
// "prompt not loaded yet" render doesn't lock in the wrong fallback.
let duration = taskDurationCache.get(j.job_token);
if (!duration) {
const model = modelType
? ALL_MODELS_LIST.find(
(m) => m.tauriId === modelType || m.id === modelType,
)
: undefined;
if (duration === undefined) {
const model = findModelByKey(modelType);
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
taskDurationCache.set(j.job_token, duration);
if (modelType) {
taskDurationCache.set(j.job_token, duration);
}
}
// Always use time-based fake progress (matching desktop app behavior)
@@ -663,7 +666,8 @@ export const TaskQueue = () => {
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const j of jobs) {
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
if (j.request.maybe_prompt_token)
tokens.push(j.request.maybe_prompt_token);
}
return tokens;
}, [jobs]);
@@ -710,7 +714,7 @@ export const TaskQueue = () => {
primaryActionText: "",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName: "",
onConfirm: async () => { },
onConfirm: async () => {},
});
// ── Confirmation handlers ──────────────────────────────────────────
@@ -814,7 +818,9 @@ export const TaskQueue = () => {
const failedJobs = fetchedJobs.filter((j) =>
FAILED_STATUSES.has(j.status.status),
);
const newCompletedIdSet = new Set(completedJobs.map((j) => j.job_token));
const newCompletedIdSet = new Set(
completedJobs.map((j) => j.job_token),
);
const newFailedIdSet = new Set(failedJobs.map((j) => j.job_token));
if (!initialLoadDoneRef.current) {
@@ -1,4 +1,4 @@
import { MediaUploadApi } from "@storyteller/api";
import { EIntermediateFile, MediaUploadApi } from "@storyteller/api";
import { UploaderState, UploaderStates } from "@storyteller/common";
export type UploadImageFn = (args: {
@@ -21,6 +21,9 @@ export const uploadImage: UploadImageFn = async ({
blob: assetFile,
fileName: assetFile.name || `reference-${Date.now()}`,
maybe_title: `ref_image_${title}`,
// User-uploaded reference images should appear in their library, not be
// hidden as intermediate system files.
is_intermediate_system_file: EIntermediateFile.false,
});
if (!response?.success || !response.data) {
+1 -1
View File
@@ -18,7 +18,7 @@ if (import.meta.env.DEV) {
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
StorytellerApiHostStore.getInstance().setDevelopment();
//StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -0,0 +1,400 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird, faSparkles } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import {
PromptBoxVFX,
TruchetPattern,
VFXResultCard,
useVFXStore,
} from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useAuthCheck } from "../../components/generation-gallery";
import { toast } from "../../components/toast/toast";
import { uploadImage } from "../../components/prompt-box/upload-image";
import { uploadVideo } from "../../components/prompt-box/upload-media";
import {
enqueueBackgroundChangeGeneration,
listSessionBackgroundChangeJobs,
startBackgroundChangePolling,
} from "./generate-background-change-api";
export default function CreateVFX() {
const { user, authChecked } = useAuthCheck();
const history = useVFXStore((s) => s.history);
const startResult = useVFXStore((s) => s.startResult);
const attachJobToken = useVFXStore((s) => s.attachJobToken);
const completeResult = useVFXStore((s) => s.completeResult);
const failResult = useVFXStore((s) => s.failResult);
const dismissResult = useVFXStore((s) => s.dismissResult);
const seedFromSession = useVFXStore((s) => s.seedFromSession);
const updateMediaForResult = useVFXStore((s) => s.updateMediaForResult);
const source = useVFXStore((s) => s.source);
const reference = useVFXStore((s) => s.reference);
const prompt = useVFXStore((s) => s.prompt);
const promptBoxRef = useRef<HTMLDivElement>(null);
const [promptBoxHeight, setPromptBoxHeight] = useState(96);
const [isSubmitting, setIsSubmitting] = useState(false);
const pollersRef = useRef(new Map<string, () => void>());
useEffect(() => {
const el = promptBoxRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const update = () => setPromptBoxHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
const pollers = pollersRef.current;
return () => {
pollers.forEach((cancel) => cancel());
pollers.clear();
};
}, []);
// On mount, reconcile against the server: pull recent SwitchX jobs from the
// session so the page shows in-progress / completed / failed cards across
// refreshes and devices, not just from localStorage. Then resume polling
// for anything that's still pending.
useEffect(() => {
if (!user) return;
let cancelled = false;
const pollers = pollersRef.current;
const startPollFor = (
resultId: string,
jobToken: string,
) => {
if (pollers.has(resultId)) return;
const cancel = startBackgroundChangePolling(
jobToken,
(output) => {
completeResult(resultId, output.cdn_url);
pollers.delete(resultId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failResult(resultId, reason);
pollers.delete(resultId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollers.set(resultId, cancel);
};
(async () => {
try {
const sessionJobs = await listSessionBackgroundChangeJobs();
if (cancelled) return;
const existing = useVFXStore.getState().history;
const existingByToken = new Map(
existing
.filter((r) => r.inferenceJobToken)
.map((r) => [r.inferenceJobToken!, r] as const),
);
for (const job of sessionJobs) {
const found = existingByToken.get(job.jobToken);
if (found) {
// Server may know more than we do (e.g. completed while we were
// away). Reconcile terminal states.
if (found.status === "pending" && job.status === "complete" && job.outputUrl) {
completeResult(found.id, job.outputUrl);
} else if (found.status === "pending" && job.status === "failed") {
failResult(found.id, job.failureReason ?? "Generation failed");
}
// Patch in CDN media + prompt from the batch-prompt response so
// dead blob: URLs (from a prior session) are replaced.
updateMediaForResult(found.id, {
source: job.source
? {
id: job.source.mediaToken,
url: job.source.url,
mediaToken: job.source.mediaToken,
}
: undefined,
reference: job.reference
? {
id: job.reference.mediaToken,
url: job.reference.url,
mediaToken: job.reference.mediaToken,
}
: undefined,
prompt: job.prompt,
});
continue;
}
// Missing locally — seed it.
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
seedFromSession({
id,
status: job.status,
prompt: job.prompt,
resolution: "720p",
source: job.source
? {
id: job.source.mediaToken,
url: job.source.url,
mediaToken: job.source.mediaToken,
}
: undefined,
reference: job.reference
? {
id: job.reference.mediaToken,
url: job.reference.url,
mediaToken: job.reference.mediaToken,
}
: undefined,
inferenceJobToken: job.jobToken,
outputUrl: job.outputUrl,
failureReason: job.failureReason,
createdAt: job.createdAt,
});
}
if (cancelled) return;
// Resume polling for everything still pending (local + freshly seeded).
const pending = useVFXStore
.getState()
.history.filter((r) => r.status === "pending" && r.inferenceJobToken);
for (const item of pending) {
startPollFor(item.id, item.inferenceJobToken!);
}
} catch {
// Network errors during session reconciliation are non-fatal — fall
// back to local-only behavior by polling whatever localStorage had.
if (cancelled) return;
const pending = useVFXStore
.getState()
.history.filter((r) => r.status === "pending" && r.inferenceJobToken);
for (const item of pending) {
startPollFor(item.id, item.inferenceJobToken!);
}
}
})();
return () => {
cancelled = true;
};
}, [user, completeResult, failResult, seedFromSession, updateMediaForResult]);
const handleSubmit = useCallback(async () => {
if (!source || !reference || isSubmitting) return;
setIsSubmitting(true);
const id = startResult();
const enqueueResult = await enqueueBackgroundChangeGeneration({
sourceVideoMediaToken: source.mediaToken,
referenceImageMediaToken: reference.mediaToken,
prompt: prompt.trim() || null,
});
setIsSubmitting(false);
if (!enqueueResult.success) {
const message = enqueueResult.backendUnavailable
? "Background change backend coming soon. Your inputs are saved."
: enqueueResult.error;
failResult(id, message);
if (enqueueResult.backendUnavailable) {
toast.success(message);
} else {
toast.error(message);
}
return;
}
attachJobToken(id, enqueueResult.jobToken);
window.dispatchEvent(new Event("task-queue-update"));
const cancel = startBackgroundChangePolling(
enqueueResult.jobToken,
(output) => {
completeResult(id, output.cdn_url);
pollersRef.current.delete(id);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failResult(id, reason);
pollersRef.current.delete(id);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollersRef.current.set(id, cancel);
}, [
source,
reference,
prompt,
isSubmitting,
startResult,
attachJobToken,
completeResult,
failResult,
]);
if (!authChecked) {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-4xl text-primary/80"
/>
</div>
);
}
if (!user) {
return (
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
/>
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
<FontAwesomeIcon
icon={faSparkles}
className="mb-6 text-5xl text-white/20"
/>
<h1 className="mb-3 text-4xl font-bold">Background Change</h1>
<p className="mb-8 max-w-md text-center text-lg text-white/60">
Sign in to swap the backdrop of a video using a reference image.
</p>
<div className="flex gap-3">
<Link to="/login">
<Button
variant="primary"
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="px-6 py-2.5 font-semibold shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="relative min-h-screen w-full bg-[#101014] text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
/>
{/* Truchet pattern is fixed to the viewport so it stays put as the
page scrolls behind the (also fixed) navbar. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 45%, black 25%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 45%, black 25%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.7}
className="absolute inset-0 h-full w-full"
/>
</div>
{history.length === 0 ? (
<div
className="relative z-10 flex min-h-screen items-center justify-center px-3 sm:px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 32, 240) }}
>
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
) : (
<div
className="relative z-10"
style={{
paddingTop: 60 + 24,
paddingBottom: Math.max(promptBoxHeight + 32, 240),
}}
>
<div className="flex flex-col items-center gap-10 px-3 sm:px-6 sm:pt-[18px]">
{history.map((r) => (
<VFXResultCard
key={r.id}
data={{
prompt: r.prompt,
resolution: r.resolution,
source: r.source,
mask: r.mask,
reference: r.reference,
outputUrl: r.outputUrl,
status: r.status,
failureReason: r.failureReason,
}}
onDismiss={() => dismissResult(r.id)}
className="w-[min(960px,calc(100vw-32px))]"
/>
))}
</div>
</div>
)}
<div
aria-hidden
className="pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-[#101014] via-[#101014]/85 to-transparent"
/>
<div
ref={promptBoxRef}
className="animate-fade-in-up pointer-events-none fixed bottom-2 left-1/2 z-30 -translate-x-1/2 sm:bottom-3"
style={{ animationDelay: "150ms" }}
>
<div className="pointer-events-auto w-[min(620px,calc(100vw-32px))]">
<PromptBoxVFX
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
uploadVideo={uploadVideo}
uploadImage={uploadImage}
onError={(msg) => toast.error(msg)}
hideResolution
/>
</div>
</div>
</div>
);
}
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
<FontAwesomeIcon icon={faSparkles} className="text-2xl text-white/40" />
</div>
<h3 className="text-2xl font-bold text-white">{title}</h3>
<p className="text-sm text-white/60 max-w-xs">{subtitle}</p>
</div>
);
@@ -0,0 +1,310 @@
import { JobsApi, PromptsApi } from "@storyteller/api";
import {
newIdempotencyToken,
submitVFXJob,
VFX_NOT_AVAILABLE_ERROR,
} from "@storyteller/ui-vfx";
export interface GeneratedBackgroundChange {
cdn_url: string;
}
// ── Request params ───────────────────────────────────────────────────────
export interface GenerateBackgroundChangeParams {
sourceVideoMediaToken: string;
referenceImageMediaToken?: string | null;
prompt?: string | null;
}
export type EnqueueResult =
| { success: true; jobToken: string }
| { success: false; error: string; backendUnavailable?: boolean };
// ── Enqueue generation ───────────────────────────────────────────────────
export async function enqueueBackgroundChangeGeneration(
params: GenerateBackgroundChangeParams,
): Promise<EnqueueResult> {
const trimmedPrompt = params.prompt?.trim();
const response = await submitVFXJob({
source_video_media_token: params.sourceVideoMediaToken,
reference_image_media_token: params.referenceImageMediaToken ?? null,
prompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : null,
uuid_idempotency_token: newIdempotencyToken(),
});
if (response.success) {
return { success: true, jobToken: response.inference_job_token };
}
return {
success: false,
error: response.error_message ?? "Failed to submit background change job.",
backendUnavailable: response.error_code_str === VFX_NOT_AVAILABLE_ERROR,
};
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollBackgroundChangeJobResult(
jobToken: string,
): Promise<{
status: "pending" | "complete" | "failed";
output?: GeneratedBackgroundChange;
error?: string;
}> {
const jobsApi = new JobsApi();
const response = await jobsApi.GetJobByToken({ token: jobToken });
if (!response.success || !response.data) {
return { status: "pending" };
}
const state = response.data;
const statusStr = state.status?.status?.toLowerCase() ?? "";
if (statusStr === "complete_success" || statusStr === "complete") {
const result = state.maybe_result as Record<string, unknown> | undefined;
const cdnUrl =
((result as any)?.media_links?.cdn_url as string | undefined) ??
((result as any)?.cdn_url as string | undefined) ??
undefined;
if (cdnUrl) {
return { status: "complete", output: { cdn_url: cdnUrl } };
}
return {
status: "failed",
error: "Generation finished but returned no output URL.",
};
}
if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead"
) {
return {
status: "failed",
error:
state.status?.maybe_failure_message ??
state.status?.maybe_extra_status_description ??
"Generation failed",
};
}
return { status: "pending" };
}
// ── Session sync ─────────────────────────────────────────────────────────
/**
* Heuristic match for any Beeble SwitchX model variant the backend may emit.
* Matches: `switch_x`, `switchx`, `beeble_switchx`, `beeble_switch_x`,
* `Beeble.SwitchX`, etc.
*/
const isSwitchXModel = (modelType?: string | null): boolean => {
if (!modelType) return false;
const normalized = modelType.toLowerCase().replace(/\./g, "_");
return normalized.includes("switchx") || normalized.includes("switch_x");
};
export interface SessionMediaRef {
url: string;
mediaToken: string;
}
export interface SessionBackgroundChangeJob {
jobToken: string;
status: "pending" | "complete" | "failed";
prompt: string;
source?: SessionMediaRef;
reference?: SessionMediaRef;
outputUrl?: string;
failureReason?: string;
createdAt: number;
}
/**
* Fetch the user's recent jobs from `/v1/jobs/session` and filter down to
* Beeble SwitchX (background-change) jobs. Used on mount so the page reflects
* server-side state across refreshes and devices, not just localStorage.
*
* The model type may live on `job.request.maybe_model_type` OR on the prompt
* (looked up by `maybe_prompt_token`) — that's the same fallback chain the
* task queue / video page use, so we match here too.
*/
export async function listSessionBackgroundChangeJobs(): Promise<
SessionBackgroundChangeJob[]
> {
const jobsApi = new JobsApi();
const response = await jobsApi.ListRecentJobs();
if (!response.success || !response.data) return [];
// Resolve any missing model_types via the prompts API.
const promptTokens = Array.from(
new Set(
response.data
.map((j) => j.request.maybe_prompt_token)
.filter((t): t is string => !!t),
),
);
const promptsByToken = new Map<
string,
{
maybe_model_type?: string | null;
maybe_positive_prompt?: string | null;
maybe_context_images?: Array<{
media_token: string;
media_links: {
cdn_url: string;
maybe_video_previews?: unknown;
};
semantic: string;
}> | null;
}
>();
if (promptTokens.length > 0) {
try {
const promptsApi = new PromptsApi();
const promptsResponse = await promptsApi.BatchGetPrompts({
tokens: promptTokens,
});
if (promptsResponse.success && promptsResponse.data) {
for (const p of promptsResponse.data) {
promptsByToken.set(p.token, p);
}
}
} catch {
// Non-fatal: fall back to job.request.maybe_model_type only.
}
}
const out: SessionBackgroundChangeJob[] = [];
for (const job of response.data) {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
? promptsByToken.get(promptToken)
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type;
if (!isSwitchXModel(modelType)) continue;
const statusStr = job.status?.status?.toLowerCase() ?? "";
let status: SessionBackgroundChangeJob["status"];
let outputUrl: string | undefined;
let failureReason: string | undefined;
if (statusStr === "complete_success" || statusStr === "complete") {
status = "complete";
const result = job.maybe_result as unknown as
| Record<string, unknown>
| undefined;
outputUrl =
((result as any)?.media_links?.cdn_url as string | undefined) ??
((result as any)?.cdn_url as string | undefined);
} else if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead" ||
statusStr.includes("cancel")
) {
status = "failed";
failureReason =
job.status?.maybe_failure_message ??
job.status?.maybe_extra_status_description ??
"Generation failed";
} else {
status = "pending";
}
// Extract source video + reference image from the prompt's context images.
// We don't depend on exact semantic strings — videos are the items whose
// media_links carry video previews, images are everything else.
let source: SessionMediaRef | undefined;
let reference: SessionMediaRef | undefined;
const ctxImages = cachedPrompt?.maybe_context_images ?? [];
for (const ci of ctxImages) {
const isVideo = !!ci.media_links?.maybe_video_previews;
if (isVideo && !source) {
source = { url: ci.media_links.cdn_url, mediaToken: ci.media_token };
} else if (!isVideo && !reference) {
reference = {
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
}
if (source && reference) break;
}
const resolvedPrompt =
cachedPrompt?.maybe_positive_prompt ??
job.request.maybe_raw_inference_text ??
"";
out.push({
jobToken: job.job_token,
status,
prompt: resolvedPrompt,
source,
reference,
outputUrl,
failureReason,
createdAt: new Date(job.created_at).getTime(),
});
}
return out;
}
// ── Polling controller ───────────────────────────────────────────────────
export function startBackgroundChangePolling(
jobToken: string,
onComplete: (output: GeneratedBackgroundChange) => void,
onError: (reason: string) => void,
intervalMs = 4000,
maxAttempts = 75, // ~5 min at 4s — Beeble SwitchX expected runtime
): () => void {
let attempts = 0;
let stopped = false;
const poll = async () => {
if (stopped) return;
attempts++;
try {
const result = await pollBackgroundChangeJobResult(jobToken);
if (stopped) return;
if (result.status === "complete" && result.output) {
onComplete(result.output);
return;
}
if (result.status === "failed") {
onError(result.error ?? "Generation failed");
return;
}
if (attempts >= maxAttempts) {
onError("Generation timed out");
return;
}
setTimeout(poll, intervalMs);
} catch {
if (!stopped && attempts < maxAttempts) {
setTimeout(poll, intervalMs * 2);
} else if (!stopped) {
onError("Network error during polling");
}
}
};
// Start first poll after a short delay (video jobs take time to start)
setTimeout(poll, 3000);
return () => {
stopped = true;
};
}
@@ -0,0 +1 @@
export { default } from "./create-vfx";
@@ -93,6 +93,9 @@
},
{
"path": "../../libs/api/tsconfig.lib.json"
},
{
"path": "../../libs/components/vfx/tsconfig.lib.json"
}
]
}
@@ -5,6 +5,9 @@
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/components/gallery-modal"
},
@@ -1,6 +1,6 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { BetaKey } from "~/models";
import { Pagination } from "~/pages/PageEnigma/models";
import type { Pagination } from "@storyteller/ui-pagescene";
export class BetaKeysApi extends ApiManager {
public CreateBetaKey({
@@ -1,9 +1,9 @@
import {
import type {
MediaFile,
MediaInfo,
Pagination,
PaginationInfinite,
} from "~/pages/PageEnigma/models";
} from "@storyteller/ui-pagescene";
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import type { Property } from "csstype";
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { Prompts } from "~/pages/PageEnigma/models";
import type { Prompts } from "@storyteller/ui-pagescene";
export class PromptsApi extends ApiManager {
public GetPromptsByToken({
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { GenerateTtsAudioErrorType } from "~/pages/PageEnigma/enums";
import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene";
import { Visibility } from "~/enums";
export interface GenerateTtsAudioRequest {
@@ -1,10 +1,10 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import {
import type {
Pagination,
UserBookmarkBatch,
UserBookmarkByEntity,
UserBookmarkByUser,
} from "~/pages/PageEnigma/models";
} from "@storyteller/ui-pagescene";
import { authentication } from "~/signals";
export enum ScopedEntityTypes {
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { Pagination } from "~/pages/PageEnigma/models";
import type { Pagination } from "@storyteller/ui-pagescene";
import { authentication } from "~/signals";
import { ZsDataset } from "~/models/Dataset";
@@ -1,7 +1,7 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import { Weight } from "~/models";
import { Pagination, PaginationInfinite } from "~/pages/PageEnigma/models";
import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene";
import {
FilterEngineCategories,
FilterMediaClasses,
@@ -25,10 +25,9 @@ import {
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { useTabStore } from "../../pages/Stores/TabState";
import { addObject } from "../../pages/PageEnigma/signals/objectGroup/addObject";
import { set3DPageMounted } from "../../pages/PageEnigma/Editor/editor";
import { addObject, getActiveEditor } from "@storyteller/ui-pagescene";
import type { MediaItem } from "@storyteller/ui-pagescene";
import { AssetType } from "~/enums";
import type { MediaItem } from "../../pages/PageEnigma/models";
import { SPLAT_MODELS } from "@storyteller/model-list";
import {
ClassyModelSelector,
@@ -974,7 +973,6 @@ export const ImageTo3DExperience = ({
variant="primary"
className="min-w-[120px]"
onClick={() => {
set3DPageMounted(true);
useTabStore.getState().setActiveTab("3D");
setTimeout(() => {
const mediaItem = {
@@ -986,8 +984,13 @@ export const ImageTo3DExperience = ({
} as MediaItem & {
position: { x: number; y: number; z: number };
};
addObject(mediaItem);
toast.success("Added to 3D scene");
const editor = getActiveEditor();
if (editor) {
void addObject(editor, mediaItem);
toast.success("Added to 3D scene");
} else {
toast.error("3D editor isn't ready yet");
}
}, 500);
}}
>
@@ -73,17 +73,6 @@ export function UploadModal3D(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -130,8 +119,12 @@ export function UploadModal3D(props: Props) {
<UploadSuccess
title="3D model"
onOk={() => {
onClose();
// Open the gallery so the user can drag the freshly
// uploaded model onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}}
/>
);
@@ -172,7 +165,8 @@ export function UploadModal3D(props: Props) {
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
</Modal>
);
}
@@ -48,17 +48,6 @@ export function UploadModalImage(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -94,8 +83,12 @@ export function UploadModalImage(props: Props) {
<UploadSuccess
title="Image"
onOk={() => {
onClose();
// Open the gallery so the user can drag the freshly
// uploaded image onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}}
/>
);
@@ -136,7 +129,8 @@ export function UploadModalImage(props: Props) {
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
</Modal>
);
}
@@ -113,7 +113,8 @@ export function UploadImageMediaModal({
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<UploaderModalContent />
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
</Modal>
);
}
@@ -100,7 +100,8 @@ export function UploadModalMedia({
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<UploaderModalContent />
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
</Modal>
);
}
@@ -40,7 +40,6 @@ interface Props {
};
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
onLocalBytes: (bytes: ArrayBuffer, shouldFlip: boolean) => void;
}
export const UploadFilesSplat = ({
@@ -18,7 +18,7 @@ import { UploadFilesSplat } from "./UploadFilesSplat";
interface Props {
onClose: () => void;
onSuccess: (splatArrayBuffer: ArrayBuffer, shouldFlip: boolean) => void;
onSuccess: (category: FilterEngineCategories) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
@@ -52,17 +52,6 @@ export function UploadModalSplat(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
// galleryModalVisibleViewMode.value = true;
// galleryModalVisibleDuringDrag.value = true;
// onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -79,7 +68,6 @@ export function UploadModalSplat(props: Props) {
}}
onClose={onClose}
onUploadProgress={updateUploaderState}
onLocalBytes={(buffer, shouldFlip) => { onSuccess(buffer, shouldFlip); }}
/>
</div>
);
@@ -103,8 +91,12 @@ export function UploadModalSplat(props: Props) {
<UploadSuccess
title="Splat"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded splat onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
// onSuccess(selectedCategory);
}}
/>
);
@@ -145,7 +137,8 @@ export function UploadModalSplat(props: Props) {
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
</Modal>
);
}

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