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 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
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
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
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
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
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
402 changed files with 8922 additions and 47750 deletions
@@ -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,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>
);
}
@@ -1,32 +1,36 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faTriangleExclamation } from "@fortawesome/pro-solid-svg-icons";
import { useShallow } from "zustand/shallow";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/signals";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
export function ErrorDialog() {
useSignals();
const { showErrorDialog, errorDialogTitle, errorDialogMessage, setShowErrorDialog } =
usePageSceneStore(
useShallow((s) => ({
showErrorDialog: s.showErrorDialog,
errorDialogTitle: s.errorDialogTitle,
errorDialogMessage: s.errorDialogMessage,
setShowErrorDialog: s.setShowErrorDialog,
})),
);
return (
<Modal
title={errorDialogTitle.value}
title={errorDialogTitle}
titleIcon={faTriangleExclamation}
titleIconClassName="text-brand-primary"
isOpen={showErrorDialog.value}
onClose={() => (showErrorDialog.value = false)}
isOpen={showErrorDialog}
onClose={() => setShowErrorDialog(false)}
showClose={false}
>
<div>
{errorDialogMessage.value}
{errorDialogMessage}
<div className="flex justify-end">
<Button
type="button"
onClick={() => (showErrorDialog.value = false)}
onClick={() => setShowErrorDialog(false)}
variant="secondary"
>
Close
@@ -1,63 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { twMerge } from "tailwind-merge";
import { faFilm } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { generateMovieId, viewMyMovies } from "~/pages/PageEnigma/signals";
import { activeWorkflowJobs } from "~/signals";
export const MyMoviesButton = () => {
useSignals();
const activeCount = activeWorkflowJobs.value
? activeWorkflowJobs.value.length
: 0;
return (
<div className="relative">
<Button
variant="action"
onClick={() => {
generateMovieId.value = "";
viewMyMovies.value = true;
}}
>
<div className="relative flex items-center gap-2">
{activeCount > 0 ? (
<svg
className="h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<FontAwesomeIcon icon={faFilm} />
)}
<div>My Movies</div>
</div>
</Button>
<div
className={twMerge(
"invisible absolute right-[-7px] top-[-7px] flex h-[20px] w-[20px] items-center justify-center rounded-full bg-brand-primary text-[13px] font-medium text-white",
activeCount > 0 && "visible",
)}
>
{activeCount}
</div>
</div>
);
};
@@ -4,11 +4,7 @@ import { useSignals } from "@preact/signals-react/runtime";
import { faPencil, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { scene, signalScene, authentication } from "~/signals";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/signals";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import { Input } from "@storyteller/ui-input";
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
@@ -36,9 +32,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
};
const handleShowErrorDialog = (errorMessage: string) => {
errorDialogTitle.value = "Error";
errorDialogMessage.value = errorMessage;
showErrorDialog.value = true;
usePageSceneStore.getState().setErrorDialog("Error", errorMessage);
};
const handleChangeSceneTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -82,11 +82,7 @@ import toast from "react-hot-toast";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import { useImageTo3DStore } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
@@ -243,8 +239,8 @@ export const TopBar = ({ pageName }: Props) => {
const tabStore = useTabStore();
const is3DSceneReady = is3DSceneLoaded.value;
const is3DEditorReady = is3DEditorInitialized.value;
const is3DSceneReady = usePageSceneStore((s) => s.is3DSceneLoaded);
const is3DEditorReady = usePageSceneStore((s) => s.is3DEditorInitialized);
const [disableSwitcher, setDisableSwitcher] = useState(false);
const switcherThrottle = useRef(false);
@@ -663,7 +659,6 @@ export const TopBar = ({ pageName }: Props) => {
setDisableSwitcher(true);
if (tabId === "APPS") {
set3DPageMounted(false);
useTabStore.getState().setActiveTab("APPS");
setTimeout(() => {
switcherThrottle.current = false;
@@ -672,12 +667,8 @@ export const TopBar = ({ pageName }: Props) => {
return;
}
// Disable 3d engine to prevent memory leak.
if (tabId === "3D") {
set3DPageMounted(true);
} else {
set3DPageMounted(false);
}
// PageScene's mount/unmount in MainApp drives the
// engine lifecycle now — no manual flag flips needed.
useTabStore.getState().setActiveTab(tabId as TabId);
setTimeout(() => {
// Clear the throttle
@@ -18,7 +18,6 @@ import {
useStoryboardPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
export type AppId =
| "IMAGE"
@@ -290,11 +289,6 @@ export const goToApp = (action?: string) => {
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
if (action === "3D") {
set3DPageMounted(true);
} else {
set3DPageMounted(false);
}
useTabStore.getState().setActiveTab(action as TabId);
}
};
@@ -1,6 +1 @@
export enum AssetFilterOption {
ALL,
FEATURED,
MINE,
BOOKMARKED,
}
export { AssetFilterOption } from "@storyteller/ui-pagescene";
@@ -1,11 +1,4 @@
export enum AssetType {
ANIMATION = "animation",
AUDIO = "audio",
CAMERA = "camera",
CHARACTER = "character",
EXPRESSION = "expression",
OBJECT = "object",
SKYBOX = "skybox",
SHAPE = "shape",
SPLAT = "splat",
}
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable while the
// 3D scene lib owns the canonical enum identity.
export { AssetType } from "@storyteller/ui-pagescene";
@@ -1,7 +1 @@
export enum ClipGroup {
CHARACTER = "character",
CAMERA = "camera",
GLOBAL_AUDIO = "global_audio",
OBJECT = "object",
PROMPT_TRAVEL = "prompt",
}
export { ClipGroup } from "@storyteller/ui-pagescene";
@@ -1,31 +1,7 @@
export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
DIMENSIONAL = "dimensional",
}
export enum FilterMediaType {
SCENE_JSON = "scene_json",
GLB = "glb",
GLTF = "gltf",
PMX = "pmx",
PMD = "pmd",
VMD = "vmd",
}
export enum FilterEngineCategories {
ANIMATION = "animation",
AUDIO = "audio",
CHARACTER = "character",
CREATURE = "creature",
EXPRESSION = "expression",
IMAGE_PLANE = "image_plane",
LOCATION = "location",
OBJECT = "object",
SPLAT = "splat",
SCENE = "scene",
SET_DRESSING = "set_dressing",
SKYBOX = "skybox",
VIDEO_PLANE = "video_plane",
}
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable.
export {
FilterEngineCategories,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/ui-pagescene";
@@ -1,5 +1,2 @@
export enum ToastTypes {
SUCCESS,
WARNING,
ERROR,
}
// Single source of truth lives in @storyteller/ui-pagescene.
export { ToastTypes } from "@storyteller/ui-pagescene";
+7 -2
View File
@@ -1,7 +1,7 @@
import { StrictMode, useEffect } from "react";
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import { BrowserRouter } from "react-router-dom";
import { PageEnigma } from "./pages/PageEnigma/PageEnigma";
import { MainApp } from "./pages/MainApp";
import { GlobalFileDropHandler } from "./components/GlobalFileDropHandler/GlobalFileDropHandler";
import { createRoot } from "react-dom/client";
import "./styles/normalize.css";
@@ -13,6 +13,7 @@ import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { pageHeight, pageWidth, persistLogin } from "~/signals";
import { SyncStorytellerApiConfig } from "./api/SyncStorytellerApiConfig";
import { posthog } from "posthog-js";
import { SoundManager } from "@storyteller/soundboard";
config.autoAddCss = false; /* eslint-disable import/first */
@@ -84,6 +85,10 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
};
}, []);
useEffect(() => {
SoundManager.install();
}, []);
return null;
};
@@ -94,7 +99,7 @@ createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<GlobalSettingsManager env={ENV} />
<div className="topbar-spacer" data-tauri-drag-region={true} />
<PageEnigma />
<MainApp />
<GlobalFileDropHandler />
</BrowserRouter>
</StrictMode>
@@ -0,0 +1,297 @@
// Top-level shell for the artcraft app. Always-mounted chrome
// (TopBar, login + pricing modals, toaster, Tauri event listeners,
// background refresh hooks) lives here, and a single tab-driven
// switch picks the active page below it.
//
// The 3D editor (PageScene) is one of the 13 tab branches — it
// genuinely mounts/unmounts as the user moves on/off the "3D" tab,
// so the Three.js engine constructs on entry and tears down on exit
// via the EngineProvider's React lifecycle. Other pages render as
// siblings, never wrapped by EngineProvider, so they have no
// dependency on the 3D engine being present.
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { ErrorDialog } from "~/components";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import { toast, Toaster } from "@storyteller/ui-toaster";
import {
GalleryDragComponent,
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
PricingModal,
CreditsModal,
useCreditsModalStore,
} from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import {
useFlashFileDownloadErrorEvent,
useFlashUserInputErrorEvent,
useGenerationCompleteEvent,
useGenerationEnqueueFailureEvent,
useGenerationEnqueueSuccessEvent,
useGenerationFailedEvent,
useMediaFileDeletedEvent,
useTextToImageGenerationCompleteEvent,
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
DomLevels,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { UsersApi } from "~/Classes/ApiManager";
import { authentication } from "~/signals";
import { AUTH_STATUS } from "~/enums";
import { useTabStore } from "./Stores/TabState";
import { useTextToImageStore } from "./PageImage/TextToImageStore";
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
import PageDraw from "./PageDraw/PageDraw";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
interface Props {
sceneToken?: string;
}
export const MainApp = ({ sceneToken }: Props) => {
useSignals();
// Background plumbing — should keep running regardless of which tab
// is active.
useActiveJobs();
useBackgroundLoadingMedia();
// Tauri event listeners. Must always be mounted so generation/upload
// completions are surfaced no matter which tab the user is on.
useGenerationEnqueueSuccessEvent();
useGenerationEnqueueFailureEvent();
useGenerationCompleteEvent();
useGenerationFailedEvent();
const completeBatch = useTextToImageStore((s) => s.completeBatch);
useTextToImageGenerationCompleteEvent(async (event) => {
completeBatch(
event.generated_images || [],
event.maybe_frontend_subscriber_id,
);
});
useFlashUserInputErrorEvent(async (event) => {
console.log("Flash user input error event received:", event);
toast.error(event.message);
});
useFlashFileDownloadErrorEvent(async (event) => {
console.log("Flash file download error event received:", event);
toast.error(event.message || "File download failed");
});
useMediaFileDeletedEvent(async (event) => {
console.log("Media file deleted event received:", event);
await SoundManager.playFileDeleted();
toast.error("File deleted.");
});
// Session probe (runs once per shell mount) and GPU detection.
// Both are app-wide concerns, not 3D-only.
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
});
}, []);
const [, setValidGpu] = useState("unknown");
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) isValid = true;
if (fps > 15) isValid = true;
if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
setValidGpu(isValid ? "valid" : "error");
});
}, []);
const { triggerRecheck } = useLoginModalStore();
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
const disableHotkeyInput = usePageSceneStore((s) => s.disableHotkeyInput);
const enableHotkeyInput = usePageSceneStore((s) => s.enableHotkeyInput);
const currentReminderModalProps = actionReminderProps.value;
return (
<div className="w-screen">
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
<TabBody sceneToken={sceneToken} />
<GalleryDragComponent />
<ErrorDialog />
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
{currentReminderModalProps && (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={currentReminderModalProps.onClose}
reminderType={currentReminderModalProps.reminderType}
onPrimaryAction={currentReminderModalProps.onPrimaryAction}
title={currentReminderModalProps.title}
message={currentReminderModalProps.message}
primaryActionText={currentReminderModalProps.primaryActionText}
secondaryActionText={currentReminderModalProps.secondaryActionText}
onSecondaryAction={currentReminderModalProps.onSecondaryAction}
isLoading={currentReminderModalProps.isLoading}
openAiLogo={currentReminderModalProps.openAiLogo}
primaryActionIcon={currentReminderModalProps.primaryActionIcon}
primaryActionBtnClassName={
currentReminderModalProps.primaryActionBtnClassName
}
/>
)}
<PricingModal />
<CreditsModal isOpen={isCreditsOpen} onClose={closeCreditsModal} />
</div>
);
};
const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
// The 3D case stays unwrapped because Stage3DBody (lib) already
// returns a <div> wrapper. Every other page is a fragment whose
// top-level children may use position: fixed (e.g. PageDraw); the
// wrapping <div> scopes them so they don't stack as siblings of
// the TopBar at the MainApp root.
switch (tabStore.activeTabId) {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
return <div><AppsIndexPage /></div>;
case "2D":
return <div><PageDrawWithGalleryDrop /></div>;
case "IMAGE":
return (
<div>
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
);
case "VIDEO":
return <div><ImageToVideo /></div>;
case "VIDEO_FRAME_EXTRACTOR":
return <div><VideoFrameExtractor /></div>;
case "VIDEO_WATERMARK_REMOVAL":
return <div><VideoWatermarkRemover /></div>;
case "IMAGE_WATERMARK_REMOVAL":
return <div><ImageWatermarkRemover /></div>;
case "IMAGE_TO_3D_OBJECT":
return <div><ImageTo3DObject /></div>;
case "IMAGE_TO_3D_WORLD":
return <div><ImageTo3DWorld /></div>;
case "REMOVE_BACKGROUND":
return <div><RemoveBackground /></div>;
case "ANGLES":
return <div><Angles /></div>;
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
default:
return null;
}
};
// Bridges gallery-modal's onImageDrop into PageDraw's existing
// `gallery-2d-drop` window-event listener. Lives here (rather than
// inside the pagedraw lib) so we don't have to add a gallery-modal
// dep there. Mounted only when the 2D tab is active, so it doesn't
// race the 3D-tab gallery handler.
const PageDrawWithGalleryDrop = () => {
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
const canvasElement = document.querySelectorAll("canvas")[0];
if (!canvasElement) return;
const rect = canvasElement.getBoundingClientRect();
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
return;
}
window.dispatchEvent(
new CustomEvent("gallery-2d-drop", {
detail: { item, canvasPosition: { x: canvasX, y: canvasY } },
}),
);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
return <PageDraw />;
};
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import { useMemo } from "react";
import {
PageDraw as PageDrawLib,
useSceneStore,
@@ -1,188 +0,0 @@
import React from "react";
import { MediaItem } from "~/pages/PageEnigma/models";
import {
addCharacter,
addCharacterAnimation,
addCharacterAudio,
addCharacterExpression,
addGlobalAudio,
addObject,
canDrop,
currPosition,
dragItem,
overTimeline,
timelineHeight,
assetModalVisibleDuringDrag,
reopenAfterDragSignal,
} from "~/pages/PageEnigma/signals";
import { pageHeight, pageWidth } from "~/signals";
import { addShape } from "~/pages/PageEnigma/signals/shape";
import { AssetType } from "~/enums";
class DndAsset {
public dropId: string = "";
public overElement: DOMRect | null = null;
public dropOffset = 0;
public initX = 0;
public initY = 0;
public notDropText = "";
public isDragging: boolean = false;
public dragThreshold: number = 5;
constructor() {
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
}
onPointerDown(event: React.PointerEvent<HTMLDivElement>, item: MediaItem) {
if (event.button === 0) {
dragItem.value = item;
currPosition.value = {
currX: event.pageX,
currY: event.pageY,
};
this.initX = event.pageX;
this.initY = event.pageY;
this.isDragging = false;
canDrop.value = false;
this.notDropText = "";
assetModalVisibleDuringDrag.value = false;
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointermove", this.onPointerMove);
}
}
endDrag() {
if (dragItem.value) {
dragItem.value = null;
canDrop.value = false;
this.overElement = null;
overTimeline.value = false;
this.notDropText = "";
assetModalVisibleDuringDrag.value = reopenAfterDragSignal.value;
}
}
overCanvas(positionX: number, positionY: number) {
if (positionY < 69) {
return false;
}
if (positionY > pageHeight.value) {
return false;
}
return positionX <= pageWidth.value;
}
onPointerUp(event: PointerEvent) {
window.removeEventListener("pointerup", this.onPointerUp);
window.removeEventListener("pointermove", this.onPointerMove);
if (!this.isDragging) {
// It's a click, not a drag
assetModalVisibleDuringDrag.value = true;
dragItem.value = null;
currPosition.value = { currX: 0, currY: 0 };
return;
}
if (dragItem.value) {
const positionX = event.pageX;
const positionY = event.pageY;
if (this.overCanvas(positionX, positionY)) {
const mediaItem = dragItem.value;
if (mediaItem.type === AssetType.CHARACTER) {
addCharacter(dragItem.value);
}
// if (dragItem.value.type === AssetType.CAMERA) {
// console.log("Dragged In Camera Type")
// }
/*
FIXME:
THIS IS A TEMPARARY SOLUTION TO A LONG PROBLEM WITH SKYBOXES
UPDATE THIS WHEN UPLOADING SKYBOXES ARE FULLY IMPLEMENTED.
THIS IS JUST TEMPARARY!!!
*/
if (mediaItem.type === AssetType.OBJECT || mediaItem.type === AssetType.SPLAT || mediaItem.type === AssetType.SKYBOX) {
addObject(mediaItem);
}
if (mediaItem.type === AssetType.SHAPE) {
addShape(mediaItem);
}
this.endDrag();
return;
}
}
if (canDrop.value && dragItem.value) {
if (dragItem.value.type === AssetType.ANIMATION) {
addCharacterAnimation({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
}
if (dragItem.value.type === AssetType.EXPRESSION) {
addCharacterExpression({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
}
if (dragItem.value.type === AssetType.AUDIO) {
addCharacterAudio({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
addGlobalAudio({
dragItem: dragItem.value,
audioId: this.dropId,
offset: this.dropOffset,
});
}
}
this.endDrag();
}
onPointerMove(event: MouseEvent) {
if (dragItem.value) {
event.stopPropagation();
event.preventDefault();
const deltaX = event.pageX - this.initX;
const deltaY = event.pageY - this.initY;
if (
Math.abs(deltaX) > this.dragThreshold ||
Math.abs(deltaY) > this.dragThreshold
) {
this.isDragging = true;
}
currPosition.value = {
currX: this.initX + deltaX,
currY: this.initY + deltaY,
};
overTimeline.value =
event.pageY > pageHeight.value - timelineHeight.value;
if (this.overElement) {
const pos = this.overElement;
const eventY = event.pageY;
const inHeight = eventY >= pos.top && eventY <= pos.top + pos.height;
const eventX = event.pageX;
const inWidth = eventX >= pos.left && eventX <= pos.left + pos.width;
if (inHeight && inWidth) {
return;
}
canDrop.value = false;
this.dropId = "";
this.overElement = null;
this.notDropText = "";
}
}
}
}
const dragAndDrop = new DndAsset();
export default dragAndDrop;
@@ -1,12 +0,0 @@
/**
* This is designed to surface user customer facing messages as errors.
* Errors shouldn't be 404 or something confusing should be
*/
class APIManagerResponseError extends Error {
constructor(message?: string) {
super(message);
this.name = "APIManagerResponseError";
}
}
export { APIManagerResponseError };
@@ -1,14 +0,0 @@
/**
* This is designed to surface user customer facing messages as errors.
*/
type Data = { [key: string]: any };
class APIManagerResponseSuccess {
public user_message: string;
public data: Data | null;
constructor(user_message: string = "", data: Data | null = null) {
this.data = data;
this.user_message = user_message;
}
}
export { APIManagerResponseSuccess };
@@ -1,169 +0,0 @@
import { SceneManager } from "./scene_manager_api";
import * as THREE from "three";
export interface ICommand<T> {
undo(): Promise<void>;
redo(): Promise<void>;
}
export interface TransformData {
object_uuid: string;
position: THREE.Vector3;
rotation: THREE.Euler;
scale: THREE.Vector3;
}
export interface UserData {
object_uuid: string;
color: string;
visible: boolean;
locked: boolean;
}
export interface CreationData {
object_uuid: string;
name: string;
position: THREE.Vector3;
userdata: Record<string, any>;
}
export interface DeletionData {
object_uuid: string;
name: string;
position: THREE.Vector3;
userdata: Record<string, any>;
}
export interface SceneState {
scene_objects: string[];
scene_names: string[];
scene_userdata: string[];
scene_positions: string[];
scene_rotations: string[];
scene_scales: string[];
}
export type CommandInputTypes = TransformData | CreationData | DeletionData;
export class CreationSceneItem implements ICommand<string> {
private component: SceneManager;
private creationData: CreationData;
public constructor(sceneManager: SceneManager, creationData: CreationData) {
this.component = sceneManager;
this.creationData = creationData;
}
public async undo(): Promise<void> {
this.component.delete(this.creationData.object_uuid);
}
public async redo(): Promise<void> {
const object = await this.component.create(
this.creationData.userdata["media_id"],
this.creationData.name,
this.creationData.position,
);
object.position.copy(this.creationData.position);
object.uuid = this.creationData.object_uuid;
if (this.creationData.userdata["visible"] !== undefined) {
object.visible = this.creationData.userdata["visible"];
}
this.component.scene.setColor(
object.uuid,
this.creationData.userdata["color"],
);
}
}
export class DeletionSceneItem implements ICommand<string> {
private component: SceneManager;
private deletionData: DeletionData;
public constructor(sceneManager: SceneManager, creationData: DeletionData) {
this.component = sceneManager;
this.deletionData = creationData;
}
public async undo(): Promise<void> {
const object = await this.component.create(
this.deletionData.userdata["media_id"],
this.deletionData.name,
this.deletionData.position,
);
object.uuid = this.deletionData.object_uuid;
object.visible = this.deletionData.userdata["visible"];
this.component.scene.setColor(
object.uuid,
this.deletionData.userdata["color"],
);
}
public async redo(): Promise<void> {
this.component.delete(this.deletionData.object_uuid);
}
}
export class TransformSceneItem implements ICommand<string> {
private component: SceneManager;
private transformDataStart: TransformData;
private transformDataEnd: TransformData;
public constructor(
sceneManager: SceneManager,
transformDataStart: TransformData,
transformDataEnd: TransformData,
) {
this.component = sceneManager;
this.transformDataStart = transformDataStart;
this.transformDataEnd = transformDataEnd;
}
public async undo(): Promise<void> {
this.component.update(
this.transformDataStart.object_uuid,
this.transformDataStart.position,
this.transformDataStart.rotation,
this.transformDataStart.scale,
);
}
public async redo(): Promise<void> {
this.component.update(
this.transformDataEnd.object_uuid,
this.transformDataEnd.position,
this.transformDataEnd.rotation,
this.transformDataEnd.scale,
);
}
}
export class UserDataSceneItem implements ICommand<string> {
private component: SceneManager;
private startUserData: UserData;
private endUserData: UserData;
public constructor(
sceneManager: SceneManager,
startUserData: UserData,
endUserData: UserData,
) {
this.component = sceneManager;
this.startUserData = startUserData;
this.endUserData = endUserData;
}
public async undo(): Promise<void> {
this.component.scene.setColor(
this.startUserData.object_uuid,
this.startUserData.color,
);
}
public async redo(): Promise<void> {
this.component.scene.setColor(
this.endUserData.object_uuid,
this.endUserData.color,
);
}
}
@@ -1,9 +0,0 @@
export interface TimelineStateJson {
version: number;
timelineItemsJson: string;
transformJson: string;
animationJson: string;
audioJson: string;
lipsyncJson: string;
emotionJson: string;
}
@@ -1,66 +0,0 @@
import { deepCopySerializableObjects as deepCopy } from "~/utilities";
import { ClipUI } from "../../clips/clip_ui";
import AudioEngine from "../Engines/audio_engine";
import EmotionEngine from "../Engines/emotion_engine";
import LipSyncEngine from "../Engines/lip_sync_engine";
import { TimeLine } from "../timeline";
import TransformEngine from "../Engines/transform_engine";
import { TimelineStateJson } from "./EditorStateJSON/timeline_state_json";
import { CharacterAnimationEngine } from "../Engines/CharacterAnimationEngine";
export class TimelineState {
version: number;
timeline_items: ClipUI[] | undefined;
transform_engine: TransformEngine | undefined;
animation_engine: CharacterAnimationEngine | undefined;
audio_engine: AudioEngine | undefined;
lipsync_engine: LipSyncEngine | undefined;
emotion_engine: EmotionEngine | undefined;
constructor({ editorVersion }: { editorVersion: number; }) {
this.version = editorVersion;
}
public async initializeFromTimeline(timeline: TimeLine): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.timeline_items = deepCopy(timeline.timeline_items);
this.transform_engine = deepCopy(timeline.transform_engine);
this.animation_engine = deepCopy(timeline.animation_engine);
this.audio_engine = deepCopy(timeline.audio_engine);
this.lipsync_engine = deepCopy(timeline.lipSync_engine);
this.emotion_engine = deepCopy(timeline.emotion_engine);
resolve();
} catch (e) {
reject(e);
}
});
}
public async initializeFromTimelineStateJson(
jsonObject: TimelineStateJson
): Promise<void> {
return new Promise((resolve, reject) => {
try {
console.log(jsonObject);
//TODO: parse json object back into each engine object
resolve();
} catch (e) {
reject(e);
}
});
}
public async toJSON() {
const result: TimelineStateJson = {
version: this.version,
timelineItemsJson: JSON.stringify(this.timeline_items),
transformJson: JSON.stringify(this.transform_engine),
animationJson: JSON.stringify(this.animation_engine),
audioJson: JSON.stringify(this.audio_engine),
lipsyncJson: JSON.stringify(this.lipsync_engine),
emotionJson: JSON.stringify(this.emotion_engine),
};
return result;
}
}
@@ -1,308 +0,0 @@
import * as THREE from "three";
import { Retarget } from "../retargeting";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { get_media_url } from "~/Classes/ApiHelpers";
import { MMDLoader } from "three/examples/jsm/loaders/MMDLoader.js";
import { ClipUI } from "../../clips/clip_ui";
import Ijson from "~/interfaces/Ijson";
export const START_FRAME_LABEL = "CharacterStartFrameLabel";
export class CharacterAnimationEngine implements Ijson {
version: number;
characterAnimations: Map<THREE.Object3D<THREE.Object3DEventMap>, ClipUI[]> = new Map();
characterMixers = new Map<THREE.Object3D<THREE.Object3DEventMap>, THREE.AnimationMixer>();
constructor(version: number) {
this.version = version;
}
toJSON(): unknown {
// Return just an array of character object IDs
const characterIds = Array.from(this.characterAnimations.keys()).map((character) => character.uuid);
return { characters: characterIds };
}
/**
* A single character model is capable of tracking multiple animations, in its own track
* These tracks can be blended with weights or additively
* The weight change can be done via custom interpolation logic
*
* @param objectUUID The UUID of the object.
* @param clip The 3JS animation clip
* @param clipUI The clip info
*/
addCharacterAnimation(character: THREE.Object3D, clip: THREE.AnimationClip, clipUI: ClipUI) {
// If character doesn't exist, create a clip object
// If it does exist, add a new animation to the existing character by extracting it
// TODO: FIXME: Duplicate prevention?
clip.name = clipUI.media_id;
character.animations.push(clip);
console.log("Adding animation track:")
console.log(clip);
if (!this.characterAnimations.has(character)) {
this.characterAnimations.set(character, []);
this.#ensureMixerExists(character);
}
// Add the clip info to the character map and sort the clips
const characterClips = this.characterAnimations.get(character)!;
characterClips.push(clipUI);
characterClips.sort((a, b) => a.offset - b.offset);
}
/**
* @param objectUUID The UUID of the object.
* @param mediaId The media ID of the animation.
* @param clipUI The clip info
*/
async addCharacterAnimationMedia(character: THREE.Object3D, mediaId: string, clipUI: ClipUI) {
// If character doesn't exist, create a clip object
// If it does exist, add a new animation to the existing character by extracting it
// TODO: FIXME: Duplicate prevention?
const animationTrack = await this.#load_animation(character, mediaId);
this.addCharacterAnimation(character, animationTrack, clipUI);
}
#ensureMixerExists(character: THREE.Object3D) {
if (!this.characterMixers.has(character)) {
this.characterMixers.set(character, new THREE.AnimationMixer(character));
}
}
getMixer(character: THREE.Object3D) {
this.#ensureMixerExists(character);
return this.characterMixers.get(character)!;
}
#interpolateClips(character: THREE.Object3D, timestamp: number, maxTime: number) {
const clips = this.characterAnimations.get(character);
const mixer = this.getMixer(character);
// Find the two clips we're interpolating between
let prevClip: ClipUI | null = null;
let nextClip: ClipUI | null = null;
const firstClip = clips ? clips[0] : null;
const lastClip = clips ? clips[clips.length - 1] : null;
// If we're before the first clip, interpolate it in from base pose
// TODO: Replace with starting frame
if (firstClip && firstClip.offset > timestamp) {
prevClip = null;
nextClip = firstClip;
} else if (lastClip && (lastClip.offset + lastClip.length) < timestamp) { // After last clip, interpolate it out
prevClip = lastClip;
nextClip = null;
} else if (clips) {
let prevClipIndex = 0;
// Loop until the next clip is after the timestamp
while (prevClipIndex < clips.length - 1 && clips[prevClipIndex + 1].offset < timestamp) {
prevClipIndex++;
}
prevClip = clips[prevClipIndex];
nextClip = clips[prevClipIndex + 1];
}
// If the clips exist, fetch their actions. If not, evaluation is either at the start or the end.
// If evalution is on either sides, check the start/end frame tracks
// Otherwise just mark it null
const prevAnimationTrack = prevClip ?
this.getCharacterAnimationTrack(character, prevClip.media_id) :
this.getCharacterAnimationTrack(character, START_FRAME_LABEL);
// TODO: Replace with ending frame
const nextAnimationTrack = nextClip ? this.getCharacterAnimationTrack(character, nextClip.media_id) : null;
const prevAction = prevAnimationTrack ? mixer.clipAction(prevAnimationTrack) : null;
const nextAction = nextAnimationTrack ? mixer.clipAction(nextAnimationTrack) : null;
// Calculate the progress of timestamp from end of prev action to start of next action
const left = (prevClip?.offset ?? 0) + (prevClip?.length ?? 0);
const right = nextClip?.offset ?? maxTime;
// Simple Linear interpolation for now
// TODO: Take an interpolation dependency, or better yet, write a transition engine
const progress = (timestamp - left) / (right - left);
prevAction?.setEffectiveWeight(1 - progress);
nextAction?.setEffectiveWeight(progress);
// Make sure we hold that last frame for the previous action
if (prevAction) {
prevAction.clampWhenFinished = true;
}
// The next action should stay at the first frame
if (nextAction) {
nextAction.paused = true;
}
// Necessary to ensure the actions are active - the default is inactive, mixer won't do anything
prevAction?.play();
nextAction?.play();
// The clip time would still be relative to the previous clip
const clipTime = timestamp - (prevClip?.offset ?? 0);
mixer.setTime(clipTime / 1000);
}
evaluateCharacter(character: THREE.Object3D, timestamp: number, maxTime: number) {
const mixer = this.getMixer(character);
const clips = this.characterAnimations.get(character);
// Find the clip we're in right now
const currentClip = clips?.find((clip) => {
return clip.offset <= timestamp && clip.offset + clip.length >= timestamp;
});
// If timestamp not in any clip, do nothing.
// If timestamp in clip, set mixer to the timestamp inside the clip
if (!currentClip) {
// Let the interpolation function handle this actions
this.#interpolateClips(character, timestamp, maxTime)
return;
}
const clipTime = timestamp - currentClip.offset;
const animationTrack = this.getCharacterAnimationTrack(character, currentClip.media_id);
// NOTE: This shouldn't really happen unless the UI was desynced at some point from the engine
if (!animationTrack) {
return;
}
// Play only this action
const animationAction = mixer.clipAction(animationTrack);
// Since it's the only clip in this timestamp, make it full weight and make sure it's not paused (from interpolation or otherwise)
animationAction.setEffectiveWeight(1);
animationAction.paused = false;
// Necessary to ensure the actions are active - the default is inactive, mixer won't do anything
animationAction.play();
mixer.setTime(clipTime / 1000);
}
getCharacterAnimationTrack(character: THREE.Object3D, mediaId: string) {
return character.animations.find((clip) => clip.name === mediaId);
}
/** Evaluate all character animations at a given timestamp (milliseconds) */
evaluate(timestamp: number, maxTime: number) {
this.characterMixers.forEach((_, character) => {
this.evaluateCharacter(character, timestamp, maxTime);
})
}
stopCharacter(character: THREE.Object3D) {
const mixer = this.characterMixers.get(character);
console.debug("Stopping all actions for character", character);
console.debug(mixer);
mixer?.stopAllAction();
console.debug(mixer);
}
stop() {
this.characterMixers.forEach((mixer) => {
mixer.stopAllAction();
});
}
async #load_animation(character: THREE.Object3D, mediaId: string): Promise<THREE.AnimationClip> {
// Get the file URL and extract the (first) animation track from it
// TODO: Support for multiple animations in a single file?
const url = await get_media_url(mediaId);
return await new Promise((resolve) => {
if (url.includes(".glb")) {
const glbLoader = new GLTFLoader();
glbLoader.load(url, (gltf) => {
const animationClip = gltf.animations[0];
resolve(animationClip);
});
} else if (url.includes(".fbx")) {
const fbxLoader = new FBXLoader();
fbxLoader.load(url, (fbx) => {
const animationClip_1 = fbx.animations[0];
animationClip_1.tracks.forEach((track) => {
const retarget = new Retarget();
const retarget_value = retarget.retarget(track.name);
track.name = retarget_value.bone;
console.log(track);
if (retarget_value.is_special) {
// TODO: Revisit special properties later
// this.special_properties.push(retarget_value);
}
});
resolve(animationClip_1);
});
} else if (url.includes(".vmd")) {
const mmdLoader = new MMDLoader();
mmdLoader.loadAnimation(url, character as THREE.SkinnedMesh, (mmd) => {
mmd.name = url;
resolve(mmd as THREE.AnimationClip);
});
}
});
}
removeCharacter(character: THREE.Object3D) {
console.debug("Removing character from animation engine", character);
this.characterAnimations.delete(character);
this.characterMixers.delete(character);
}
removeAnimation(character: THREE.Object3D, clip: ClipUI) {
console.debug("Removing animation from character", character, clip);
const clips = this.characterAnimations.get(character)!;
const clipIndex = clips.findIndex((c) => c.clip_uuid === clip.clip_uuid);
if (clipIndex < 0) {
return;
}
// Remove the clip from character's tracked clips
clips.splice(clipIndex, 1);
// Stop all character animations to cleanly reset
this.stopCharacter(character);
}
clearStartFrame(character: THREE.Object3D, resetTime?: number, maxTime = 1) {
// Stop all character animations
this.stopCharacter(character);
// Check the animations and remove any labelled START_FRAME_LABEL
character.animations = character.animations.filter((clip) => clip.name !== START_FRAME_LABEL);
// If resetTime is passed, evaluate the character to that time
if (resetTime) {
this.evaluateCharacter(character, resetTime, maxTime);
}
}
/** resetTime: The time to reset the character to. This should ideally be the current timeline time. */
async createStartFrameAnimation(character: THREE.Object3D, poseHelper: any, url: string, resetTime: number = 0, maxTime: number = 1) {
//// Clear any existing start frame
//// This will also reset the animation to default pose
//this.clearStartFrame(character);
//// We must create the track data now after resetting to default
//const poseData = await poseHelper.extractPoseData(url);
//const tracks = poseHelper.inflatePoseDataToTracks(character, poseData);
//// Create the animation track from the rotation values and hip position
//// then add it to the character
//const startFrameClip = new THREE.AnimationClip(START_FRAME_LABEL, 0, tracks);
//character.animations.push(startFrameClip);
//// Reset the character evaluation to what was passed in
//this.evaluateCharacter(character, resetTime, maxTime);
}
}
@@ -1,48 +0,0 @@
// A mapping between the model bones names and the Mediapipe output index
export const MixamoPoseMap: Record<string, number> = {
"mixamorigLeftArm": 11,
"mixamorigRightArm": 12,
"mixamorigLeftForeArm": 13,
"mixamorigRightForeArm": 14,
"mixamorigLeftHand": 15,
"mixamorigRightHand": 16,
"mixamorigLeftUpLeg": 23,
"mixamorigRightUpLeg": 24,
"mixamorigLeftLeg": 25,
"mixamorigRightLeg": 26,
"mixamorigLeftFoot": 27,
"mixamorigRightFoot": 28,
"mixamorigLeftHandThumb2": 21,
"mixamorigRightHandThumb2": 22,
}
export const MixamoInterpolationBoneNames = {
Spine: "mixamorigSpine",
Spine1: "mixamorigSpine1",
Spine2: "mixamorigSpine2",
Neck: "mixamorigNeck",
LeftShoulderBlade: "mixamorigLeftShoulder",
DefLeftUpperArm: "DEF-upper_armL001",
DefLeftForeArm: "DEF-forearmL001",
RightShoulderBlade: "mixamorigRightShoulder",
DefRightUpperArm: "DEF-upper_armR001",
DefRightForeArm: "DEF-forearmR001",
DefLeftThigh: "DEF-thighL001",
DefRightThigh: "DEF-thighR001",
}
export const PoseMap = {
NOSE: 0,
LEFT_SHOULDER: 11,
RIGHT_SHOULDER: 12,
LEFT_ELBOW: 13,
RIGHT_ELBOW: 14,
LEFT_WRIST: 15,
RIGHT_WRIST: 16,
LEFT_HIP: 23,
RIGHT_HIP: 24,
LEFT_KNEE: 25,
RIGHT_KNEE: 26,
LEFT_ANKLE: 27,
RIGHT_ANKLE: 28,
}
@@ -1,27 +0,0 @@
import { AnimationClip } from "../../clips/animation_clip";
export class AnimationEngine {
clips: { [key: string]: AnimationClip } = {};
version: number;
constructor(version: number) {
this.clips = {};
this.version = version;
}
load_object(object_uuid: string, media_id: string, clip_name: string) {
console.debug("load_object", object_uuid, media_id, clip_name);
this.clips[object_uuid + media_id] = new AnimationClip(
this.version,
media_id,
"glb",
object_uuid,
1.0,
1.0,
clip_name,
0.0,
0.0,
);
//this.clips[object_uuid + media_id]._load_animation();
}
}
@@ -1,81 +0,0 @@
import { AudioClip } from "../../clips/audio_clip";
class AudioEngine {
clips: { [key: string]: AudioClip } = {};
audio_sources: { [key: string]: AudioBufferSourceNode } = {};
version: number;
playing: string[];
last_frame: number;
constructor() {
this.clips = {};
this.playing = [];
this.audio_sources = {};
this.version = 1.0;
this.last_frame = 0;
}
// loads clips into the engine to cache
loadClip(audio_media_id: string) {
if (this.clips[audio_media_id] != null) {
return;
}
this.clips[audio_media_id] = new AudioClip(
this.version,
audio_media_id,
1.0,
);
}
// plays from the timeline.
playClip(audio_media_id: string) {
if (this.clips[audio_media_id] == null) {
this.loadClip(audio_media_id);
}
}
async step(audio_media_id: string, frame: number, offset: number) {
const frameBuffer = 12;
const pos = frame - offset;
let doPlay = true;
if (
Math.abs(this.last_frame - frame) < frameBuffer - 1 &&
this.last_frame !== 0
) {
doPlay = false;
}
if (pos <= 1) {
doPlay = true;
}
const clip = this.clips[audio_media_id];
if (doPlay && clip.audio_data?.audioContext) {
const startTime = pos / 60.0;
const endTime = frameBuffer / 60.0;
clip.audio_data.source =
clip.audio_data.audioContext.createBufferSource();
clip.audio_data.source.buffer = clip.audio_data.audioBuffer;
clip.audio_data.source.connect(clip.audio_data.audioContext.destination);
clip.audio_data.source.start(0, startTime, endTime);
this.last_frame = frame;
}
}
// stops the clips
stopClip(audio_media_id: string) {
const clip = this.clips[audio_media_id];
if (clip.audio_data?.source) {
clip.audio_data.source.stop();
clip.audio_data.source.disconnect();
} else {
console.warn(
`AudioManager: AudioClip with id "${audio_media_id}" not found.`,
);
}
}
}
export default AudioEngine;
@@ -1,24 +0,0 @@
import { EmotionClip } from "../../clips/emotion_clip";
class EmotionEngine {
clips: { [key: string]: EmotionClip } = {};
version: number;
constructor(version: number) {
this.clips = {};
this.version = version;
}
// loads clips into the engine to cache
loadClip(object_uuid: string, csv_media_id: string) {
if (this.clips[object_uuid + csv_media_id] != null) {
return;
}
this.clips[object_uuid + csv_media_id] = new EmotionClip(
this.version,
csv_media_id,
);
}
}
export default EmotionEngine;
@@ -1,22 +0,0 @@
import { LipSyncClip } from "../../clips/lipsync_clip";
// needs to be apart of the editor window.
export default class LipSyncEngine {
clips: { [key: string]: LipSyncClip } = {};
audio_sources: { [key: string]: AudioBufferSourceNode } = {};
version: number;
constructor() {
this.clips = {};
this.audio_sources = {};
this.version = 1.0;
}
load_object(object_uuid: string, audio_media_id: string) {
this.clips[object_uuid + audio_media_id] = new LipSyncClip(
this.version,
audio_media_id,
1.0,
);
}
}
@@ -1,58 +0,0 @@
import { TransformClip } from "../../clips/transform_clip";
import * as THREE from "three";
class TransformEngine {
clips: { [key: string]: TransformClip } = {};
version: number;
constructor(version: number) {
this.version = version;
this.clips = {};
}
loadObject(object_uuid: string, clip_length: number = 2) {
this.clips[object_uuid] = new TransformClip(
this.version,
object_uuid,
clip_length,
object_uuid,
); // replace the last object_uuid with the media ID when its ready.
return this.clips[object_uuid];
}
addFrame(
object_uuid: string,
clip_length: number = 2,
position: THREE.Vector3,
rotation: THREE.Vector3,
scale: THREE.Vector3,
offset: number,
keyframe_uuid: string,
): boolean {
if (this.clips[object_uuid] == null) {
this.clips[object_uuid] = new TransformClip(
this.version,
object_uuid,
clip_length,
);
this.clips[object_uuid].add_frame(
position,
rotation,
scale,
offset,
keyframe_uuid,
);
return false;
}
this.clips[object_uuid].add_frame(
position,
rotation,
scale,
offset,
keyframe_uuid,
);
return true;
}
}
export default TransformEngine;
@@ -1,32 +0,0 @@
import { getArtStyle } from "~/enums/ArtStyle";
import {
cameraAspectRatio,
cinematic,
enginePreProcessing,
faceDetail,
globalIPAMediaToken,
lipSync,
styleStrength,
upscale,
} from "~/pages/PageEnigma/signals";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import Editor from "./editor";
export const getSceneGenerationMetaData = (
editorEngine: Editor,
): SceneGenerationMetaData => {
// when this is called, editor engine is guarunteed by it's caller
return {
positivePrompt: editorEngine.positive_prompt,
negativePrompt: editorEngine.negative_prompt,
artisticStyle: getArtStyle(editorEngine.art_style.toString()),
cameraAspectRatio: cameraAspectRatio.value,
globalIPAMediaToken: globalIPAMediaToken.value || undefined,
upscale: upscale.value,
faceDetail: faceDetail.value,
styleStrength: styleStrength.value,
lipSync: lipSync.value,
cinematic: cinematic.value,
enginePreProcessing: enginePreProcessing.value,
};
};
@@ -1,64 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
import { StorytellerApiHostStore } from "@storyteller/api";
export const uploadNewScene = async (file: File, sceneTitle: string) => {
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/new_scene`;
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
formData.append("maybe_title", sceneTitle);
formData.append("maybe_visibility", "public");
formData.append("engine_category", "scene");
return await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
},
credentials: "include",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (res && res.success) {
return res;
} else {
return { success: false };
}
})
.catch(() => {
return { success: false };
});
};
export const updateExistingScene = async (file: File, sceneToken: string) => {
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/saved_scene/${sceneToken}`;
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
return await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
},
credentials: "include",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (res && res.success) {
return res;
} else {
return { success: false };
}
})
.catch(() => {
return { success: false };
});
};
@@ -1,460 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import * as THREE from "three";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
import { signalScene, startPollingActiveJobs } from "~/signals";
import { updateExistingScene, uploadNewScene } from "./api_fetchers";
import environmentVariables from "~/Classes/EnvironmentVariables";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { StorytellerApiHostStore } from "@storyteller/api";
interface StylizeVideoParams {
media_token: string;
style: ArtStyle;
positive_prompt: string;
negative_prompt: string;
visibility: Visibility;
use_face_detailer?: boolean;
use_upscaler?: boolean;
use_strength?: number;
use_lipsync?: boolean;
use_cinematic?: boolean;
use_global_ipa_media_token?: string | null;
input_depth_file?: string | null;
input_normal_file?: string | null;
input_outline_file?: string | null;
}
/**
* Storyteller Studio API Manager
* The source of truth of all these media items is the database in the cloud
*/
export class MediaFile {
public_bucket_path: string;
media_type: string;
media_token: string;
constructor(
public_bucket_path: string,
media_type: string,
media_token: string,
) {
this.media_token = media_token;
this.public_bucket_path = public_bucket_path;
this.media_type = media_type;
}
}
export enum ArtStyle {
Anime2_5D = "anime_2_5d",
Anime2DFlat = "anime_2d_flat",
Cartoon3D = "cartoon_3d",
ComicBook = "comic_book",
AnimeGhibli = "anime_ghibli",
InkPunk = "ink_punk",
InkSplash = "ink_splash",
InkBWStyle = "ink_bw_style",
JojoStyle = "jojo_style",
PaperOrigami = "paper_origami",
PixelArt = "pixel_art",
PopArt = "pop_art",
Realistic1 = "realistic_1",
Realistic2 = "realistic_2",
AnimeRetroNeon = "anime_retro_neon",
AnimeStandard = "anime_standard",
HRGiger = "hr_giger",
Simpsons = "simpsons",
Carnage = "carnage",
PastelCuteAnime = "pastel_cute_anime",
Bloom = "bloom_lighting",
Horror2_5D = "25d_horror",
Creepy = "creepy",
CreepyVHS = "creepy_vhs",
TrailCamFootage = "trail_cam_footage",
OldBWMovie = "old_black_white_movie",
HorrorNoirBW = "horror_noir_black_white",
TechnoNoirBW = "techno_noir_black_white",
BW20s = "black_white_20s",
AnimeCyberpunk = "cyberpunk_anime",
Dragonball = "dragonball",
RealisticMatrix = "realistic_matrix",
RealisticCyberpunk = "realistic_cyberpunk",
}
export enum Visibility {
Public = "public",
Hidden = "hidden",
Private = "private",
}
/**
* This is designed to surface user customer facing messages as errors.
*/
// type Data = { [key: string]: any };
// class APIManagerResponseSuccess {
// public user_message: string;
// public data: Data | null;
// constructor(user_message: string = "", data: Data | null = null) {
// this.data = data;
// this.user_message = user_message;
// }
// }
/**
* This is designed to surface user customer facing messages as errors.
* Errors shouldn't be 404 or something confusing should be
*/
class APIManagerResponseError extends Error {
constructor(message?: string) {
super(message);
this.name = "APIManagerResponseError";
}
}
export class APIManager {
constructor() {
// NB(bt,2025-07-06): Cleaning this up. The code was duplicated a half dozen times.
// I think all of the code in this file is legacy, but to be safe I'm continuing to update it.
}
protected getApiSchemeAndHost() {
return StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
}
/**
* @param scene The 3JS Scene we want to save.
* @param scene_name The Scene name we want to display
* @param scene_media_file_token If null we will we will create a new save or copy the scene, if provided we will overwrite the scene.
* @returns APIManagerResponseMessage
*/
public async saveSceneState({
saveJson,
sceneTitle,
sceneToken,
sceneThumbnail,
}: {
saveJson: string;
sceneTitle: string;
sceneToken?: string;
sceneThumbnail: Blob | undefined;
}): Promise<string> {
const file = new File([saveJson], `${sceneTitle}.glb`, {
type: "application/json",
});
const uploadSceneResponse = sceneToken
? await updateExistingScene(file, sceneToken)
: await uploadNewScene(file, sceneTitle);
// failed so catch this.
if (uploadSceneResponse["success"] == false) {
return "";
}
if (sceneThumbnail) {
const image_resp = await this.uploadMediaSceneThumbnail(
sceneThumbnail,
"render.png",
);
if (image_resp["success"] == false) {
return "";
}
if (image_resp["media_file_token"]) {
const image_token = image_resp["media_file_token"];
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/cover_image/${uploadSceneResponse["media_file_token"]}`;
await fetch(endpoint, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ cover_image_media_file_token: image_token }),
});
}
}
console.log(uploadSceneResponse);
return uploadSceneResponse["media_file_token"];
}
public async loadSceneState(
scene_media_file_token: string | null,
): Promise<any> {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/file/${scene_media_file_token}`;
const response = await fetch(url);
if (response.status > 200) {
throw new APIManagerResponseError("Failed to load scene");
}
const json = await response.json();
if (json && json.media_file) {
if (json.media_file.maybe_title === null) {
console.warn(`Scene /w Token: ${scene_media_file_token} has no title`);
}
signalScene({
title: json.media_file.maybe_title || "Untitled Scene",
token: scene_media_file_token || undefined,
ownerToken: json.media_file.maybe_creator_user.user_token,
isModified: false,
});
}
const bucket_path = json["media_file"]["public_bucket_path"];
const media_base_url = GetCdnOrigin();
//const media_url = `${media_base_url}/vocodes-public${bucket_path}`; // gets you a bucket path
const media_url = `${media_base_url}${bucket_path}`; // gets you a bucket path
const file_response = await fetch(media_url);
if (!file_response.ok) {
throw new APIManagerResponseError("Failed to download file");
}
// Convert the response from a blob to json text
const blob = await file_response.blob();
const json_result: string = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(JSON.parse(reader.result as string));
reader.onerror = reject;
reader.readAsText(blob);
});
return json_result;
}
/**
* This recieves the bucket path of a media file
* @param media_file_token
* @returns
*/
public async getMediaFile(media_file_token: string): Promise<string> {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/file/${media_file_token}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_base_url = GetCdnOrigin();
const media_url = `${media_base_url}${bucketPath}`; // gets you a bucket path
return media_url;
}
/**
This will save the scene to keep ids positions.
It will also give the file a name which will be a uuidv4()
@param scene The 3JS Scene we want to make a file to be uploaded as multipart form.
*/
private async gltfExport(scene: THREE.Scene): Promise<File> {
const gltfExporter = new GLTFExporter();
const uuid = uuidv4();
const result = await gltfExporter.parseAsync(scene);
const file = new File([JSON.stringify(result)], `${uuid}.glb`, {
type: "application/json",
});
return file;
}
public async uploadMediaSceneThumbnail(blob: Blob | File, fileName: string) {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/upload/image`;
const uuid = uuidv4();
const formData = new FormData();
formData.append("uuid_idempotency_token", uuid);
formData.append("is_intermediate_system_file", "true");
formData.append("maybe_title", "Screenshot");
formData.append("file", blob, fileName);
//formData.append("source", "file");
//formData.append("type", "video");
//formData.append("source", "file");
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
const json_data = await response.json();
//console.log(`uploadMedia: ${JSON.stringify(json_data)}`);
return json_data;
}
}
public async uploadMedia({
blob,
fileName,
title,
styleName,
maybe_scene_source_media_file_token,
}: {
blob: Blob;
fileName: string;
title: string;
styleName?: string;
maybe_scene_source_media_file_token: string | undefined;
}) {
// Promise<APIManagerResponseSuccess>
//TODO: UPDATE ENDPOINT!!!!
const url = `${this.getApiSchemeAndHost()}/v1/media_files/upload/new_video`;
const uuid = uuidv4();
const formData = new FormData();
formData.append("uuid_idempotency_token", uuid);
formData.append("file", blob, fileName);
// formData.append("source", "file");
// formData.append("type", "video");
if (styleName) formData.append("maybe_style_name", styleName);
formData.append("maybe_title", title);
// This signals to the backend to hide the video from view
formData.append("is_intermediate_system_file", "true");
if (maybe_scene_source_media_file_token !== undefined) {
formData.append(
"maybe_scene_source_media_file_token",
maybe_scene_source_media_file_token,
);
}
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
const json_data = await response.json();
//console.log(`uploadMedia: ${JSON.stringify(json_data)}`);
return json_data;
}
}
public async uploadMediaFrameGeneration(
blob: Blob | File,
fileName: string,
style: string = "comic_book",
positive_prompt: string,
negative_prompt: string,
): Promise<string> {
const url = `${environmentVariables.values.FUNNEL_API}/preview/`;
const payload = {
style: style,
positive_prompt: positive_prompt,
negative_prompt: negative_prompt,
};
const formData = new FormData();
formData.append("input_file", blob, fileName);
formData.append("request", JSON.stringify(payload));
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
return URL.createObjectURL(await response.blob());
}
}
public async getMediaBatch(media_tokens: string[]): Promise<MediaFile[]> {
const tokens = media_tokens;
const url = new URL(`${this.getApiSchemeAndHost()}/v1/media_files/batch`);
tokens.forEach((token) => url.searchParams.append("tokens", token));
const result = await fetch(url)
.then((response) => response.json())
.then((data) => {
const result = data["media_files"].map((element: any) => {
return new MediaFile(
element["public_bucket_path"],
element["media_type"],
element["token"],
);
});
return result;
})
.catch((error) => console.error("Error:", error));
return result;
}
public async stylizeVideo({
media_token,
style,
positive_prompt,
negative_prompt,
visibility,
use_face_detailer = false,
use_upscaler = false,
use_strength = 1.0,
use_lipsync = false,
use_cinematic = false,
use_global_ipa_media_token = null,
input_depth_file = null,
input_normal_file = null,
input_outline_file = null,
}: StylizeVideoParams) {
const uuid = uuidv4();
const data = {
uuid_idempotency_token: uuid,
style: style,
input_file: media_token,
prompt: positive_prompt,
negative_prompt: negative_prompt,
trim_start_millis: 0,
trim_end_millis: 7000,
enable_lipsync: use_lipsync,
creator_set_visibility: visibility,
use_face_detailer: use_face_detailer,
use_upscaler: use_upscaler,
use_strength: use_strength,
use_lipsync: use_lipsync,
global_ipa_media_token: use_global_ipa_media_token,
use_cinematic: use_cinematic,
input_depth_file: input_depth_file,
input_normal_file: input_normal_file,
input_outline_file: input_outline_file,
};
const json_data = JSON.stringify(data);
const response = await fetch(
`${this.getApiSchemeAndHost()}/v1/workflows/enqueue_studio`,
{
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: json_data,
},
);
startPollingActiveJobs();
if (!response.ok) {
// Handle HTTP error responses
const errorData = await response.json();
throw new Error(`API Error: ${response.status} ${errorData.message}`);
}
// Assuming the response is JSON and matches the EnqueueVideoStyleTransferSuccessResponse interface
return await response.json();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,445 +0,0 @@
import {
EventDispatcher,
Quaternion,
Vector3,
Vector2,
MathUtils,
} from "three";
import * as THREE from "three";
import { cameras, selectedCameraId } from "~/pages/PageEnigma/signals/camera";
import { isPromptBoxFocused } from "~/pages/PageEnigma/signals/promptBox";
export const isSafariOrWebKit = () => {
const ua = navigator.userAgent;
const isWebKit = /AppleWebKit/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS|FxiOS/i.test(ua);
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
return isSafari || isWebKit;
};
export const hasApplePay = () => {
// @ts-expect-error Apple Pay is not defined in TypeScript
return !!window.ApplePaySession;
}
export const isSafari = () => {
return isSafariOrWebKit() || hasApplePay();
}
export const isPointerLockSupported = () => {
return !isSafari();
}
class FreeCam extends EventDispatcher {
object: THREE.PerspectiveCamera;
domElement: HTMLElement | Document;
enabled = true;
movementSpeed = 1.0;
rollSpeed = 0.005;
dragToLook = false;
autoForward = false;
dragging = false;
xOffset = 0;
yOffset = 0;
// Smoothing properties
smoothingFactor = 0.2;
currentVelocity = new Vector3();
lastPosition = new Vector3();
lastMousePosition = new Vector2();
mouseVelocity = new Vector2();
tmpQuaternion = new Quaternion();
status = 0;
moveState = {
up: 0,
down: 0,
left: 0,
right: 0,
forward: 0,
back: 0,
pitchUp: 0,
pitchDown: 0,
yawLeft: 0,
yawRight: 0,
rollLeft: 0,
rollRight: 0,
};
moveVector = new Vector3(0, 0, 0);
rotationVector = new Vector3(0, 0, 0);
constructor(
object: THREE.PerspectiveCamera,
domElement: HTMLElement | Document,
) {
super();
this.object = object;
this.domElement = domElement;
this.lastPosition.copy(object.position);
this.keydown = this.keydown.bind(this);
this.keyup = this.keyup.bind(this);
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mousewheel = this.mousewheel.bind(this);
this.reset = this.reset.bind(this);
this.update = this.update.bind(this);
this.updateMovementVector = this.updateMovementVector.bind(this);
this.updateRotationVector = this.updateRotationVector.bind(this);
this.getContainerDimensions = this.getContainerDimensions.bind(this);
this.dispose = this.dispose.bind(this);
this.domElement.addEventListener("contextmenu", function (event) {
event.preventDefault();
});
this.updateMovementVector();
this.updateRotationVector();
}
attachEventListeners() {
window.addEventListener("keydown", this.keydown);
window.addEventListener("keyup", this.keyup);
window.addEventListener("mousedown", this.mousedown);
window.addEventListener("mouseup", this.mouseup);
window.addEventListener("mousemove", this.mousemove);
window.addEventListener("wheel", this.mousewheel);
}
detachEventListeners() {
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
window.removeEventListener("mousedown", this.mousedown);
window.removeEventListener("mouseup", this.mouseup);
window.removeEventListener("mousemove", this.mousemove);
window.removeEventListener("wheel", this.mousewheel);
}
keydown(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 1;
break;
case "KeyS":
this.moveState.back = 1;
break;
case "KeyA":
this.moveState.left = 1;
break;
case "KeyD":
this.moveState.right = 1;
break;
case "KeyQ":
this.moveState.down = 1;
break;
case "KeyE":
this.moveState.up = 1;
break;
case "ArrowUp":
this.moveState.pitchUp = 1;
break;
case "ArrowDown":
this.moveState.pitchDown = 1;
break;
case "ArrowLeft":
this.moveState.yawLeft = 1;
break;
case "ArrowRight":
this.moveState.yawRight = 1;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
keyup(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 0;
break;
case "KeyS":
this.moveState.back = 0;
break;
case "KeyA":
this.moveState.left = 0;
break;
case "KeyD":
this.moveState.right = 0;
break;
case "ArrowUp":
this.moveState.pitchUp = 0;
break;
case "ArrowDown":
this.moveState.pitchDown = 0;
break;
case "ArrowLeft":
this.moveState.yawLeft = 0;
break;
case "ArrowRight":
this.moveState.yawRight = 0;
break;
case "KeyQ":
this.moveState.down = 0;
break;
case "KeyE":
this.moveState.up = 0;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
mousedown(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.dragging = true;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
}
mouseup(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
this.dragging = false;
}
mousemove(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (this.dragging) {
const mouseX = event.clientX - this.xOffset;
const mouseY = event.clientY - this.yOffset;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
if (Math.abs(mouseX + mouseY) > 0) {
// Store last position before movement
this.lastPosition.copy(this.object.position);
// Apply movement with smoothing
const translationX = -mouseX * (this.movementSpeed * 0.01);
const translationY = mouseY * (this.movementSpeed * 0.01);
this.currentVelocity.x = MathUtils.lerp(
this.currentVelocity.x,
translationX,
this.smoothingFactor,
);
this.currentVelocity.y = MathUtils.lerp(
this.currentVelocity.y,
translationY,
this.smoothingFactor,
);
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
}
}
}
mousewheel(event: WheelEvent) {
if (!this.enabled) {
return;
}
// Store last position before zoom
this.lastPosition.copy(this.object.position);
const zoomDelta = event.deltaY / 120;
this.currentVelocity.z = MathUtils.lerp(
this.currentVelocity.z,
zoomDelta,
this.smoothingFactor,
);
this.object.translateZ(this.currentVelocity.z);
}
resetVelocity() {
this.currentVelocity.set(0, 0, 0);
this.lastPosition.copy(this.object.position);
}
reset() {
this.moveState.forward = 0;
this.moveState.back = 0;
this.moveState.left = 0;
this.moveState.right = 0;
this.moveState.pitchUp = 0;
this.moveState.pitchDown = 0;
this.moveState.yawLeft = 0;
this.moveState.yawRight = 0;
this.moveState.down = 0;
this.moveState.up = 0;
this.resetVelocity();
this.updateMovementVector();
this.updateRotationVector();
}
isStationary() {
return (
this.moveVector.distanceTo(new Vector3(0, 0, 0)) === 0 &&
this.currentVelocity.distanceTo(new Vector3(0, 0, 0)) < 0.0001
);
}
isRotating() {
return (
this.moveState.pitchUp ||
this.moveState.pitchDown ||
this.moveState.yawLeft ||
this.moveState.yawRight
);
}
update(delta: number) {
if (!this.enabled) {
return;
}
// Skip update if there's no movement, velocity, or rotation
if (this.isStationary() && !this.isRotating()) {
return;
}
const moveMulti = delta * this.movementSpeed;
// Apply WASD movement with smoothing
const targetMovement = new Vector3(
this.moveVector.x * moveMulti,
this.moveVector.y * moveMulti,
this.moveVector.z * moveMulti,
);
// Smooth out the movement
this.currentVelocity.lerp(targetMovement, this.smoothingFactor);
// Apply the smoothed movement
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
this.object.translateZ(this.currentVelocity.z);
// Store last position
this.lastPosition.copy(this.object.position);
// Done with positions
// Now handle rotations
this.object.rotateX(this.rotationVector.x);
this.object.rotateY(this.rotationVector.y);
this.object.rotateZ(this.rotationVector.z);
// Update camera signals
if (selectedCameraId.value) {
const pos = this.object.position;
const rot = this.object.rotation;
// Calculate lookAt point based on camera's forward direction
const lookAtPoint = new Vector3(0, 0, -1);
lookAtPoint.applyQuaternion(this.object.quaternion);
lookAtPoint.add(pos);
cameras.value = cameras.value.map((cam) =>
cam.id === selectedCameraId.value
? {
...cam,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
lookAt: { x: lookAtPoint.x, y: lookAtPoint.y, z: lookAtPoint.z },
}
: cam,
);
}
}
updateMovementVector() {
const forward =
this.moveState.forward || (this.autoForward && !this.moveState.back)
? 1
: 0;
this.moveVector.x = -this.moveState.left + this.moveState.right;
this.moveVector.y = -this.moveState.down + this.moveState.up;
this.moveVector.z = -forward + this.moveState.back;
}
updateRotationVector() {
this.rotationVector.x = -this.moveState.pitchDown + this.moveState.pitchUp;
this.rotationVector.y = -this.moveState.yawRight + this.moveState.yawLeft;
this.rotationVector.z = -this.moveState.rollRight + this.moveState.rollLeft;
this.rotationVector.multiplyScalar(this.rollSpeed);
}
getContainerDimensions() {
if (this.domElement !== document) {
return {
size: [
(this.domElement as HTMLElement).offsetWidth,
(this.domElement as HTMLElement).offsetHeight,
],
offset: [
(this.domElement as HTMLElement).offsetLeft,
(this.domElement as HTMLElement).offsetTop,
],
};
} else {
return {
size: [window.innerWidth, window.innerHeight],
offset: [0, 0],
};
}
}
dispose() {
// window.addEventListener('contextmenu', function(event) {
// event.preventDefault();
// });
// this.domElement.removeEventListener("contextmenu", this.contextmenu);
// this.domElement.removeEventListener("pointerdown", this.pointerdown);
// this.domElement.removeEventListener("pointermove", this.pointermove);
// this.domElement.removeEventListener("pointerup", this.pointerup);
// this.domElement.removeEventListener("pointercancel", this.pointercancel);
//
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
}
}
export { FreeCam };
@@ -1,299 +0,0 @@
import Scene from "./scene";
import Editor from "./editor";
import * as THREE from "three";
import { XYZ } from "../datastructures/common";
import { editorState } from "../signals/engine";
import { EditorStates } from "../enums";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import { AssetType } from "~/enums";
import { hideObjectPanel } from "../signals";
export class SceneUtils {
scene: Scene;
editor: Editor;
constructor(editor: Editor, scene: Scene) {
this.scene = scene;
this.editor = editor;
}
// If string is empty.
isEmpty(value: string): boolean {
return (
value == null || (typeof value === "string" && value.trim().length === 0)
);
}
// For react to see if an object has lipsync capability or not.
isObjectLipsync(object_uuid: string) {
const object = this.scene.get_object_by_uuid(object_uuid);
let hasLipsync = false;
if (object) {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
const blendShapeIndexE = c.morphTargetDictionary["E"];
// console.log(c.morphTargetDictionary, blendShapeIndexE)
if (blendShapeIndexE !== null) {
hasLipsync = true;
}
}
}
});
}
return hasLipsync;
}
// Returns if the object is locked or unlocked.
isObjectLocked(object_uuid: string): boolean {
const object = this.scene.get_object_by_uuid(object_uuid);
if (object) {
if (object.userData["locked"] == undefined) {
object.userData["locked"] = false;
}
return object.userData["locked"];
}
//console.log("No object found.");
return false;
}
// Locks or unlocks and object and returns its new state,
lockUnlockObject(object_uuid: string): boolean {
const object = this.scene.get_object_by_uuid(object_uuid);
if (object) {
if (object.userData["locked"] == undefined) {
object.userData["locked"] = false;
}
object.userData["locked"] = !object.userData["locked"];
if (object.userData["locked"]) {
this.removeTransformControls(false);
} else if (this.editor.control) {
this.scene.scene.add(this.editor.control);
if (this.editor.sceneManager?.selected_objects)
this.editor.control.attach(
this.editor.sceneManager?.selected_objects[0],
);
}
return object.userData["locked"];
}
//console.log("No object found.");
return false;
}
// Removes transform controls and publishes selected.
removeTransformControls(remove_outline: boolean = true) {
if (this.editor.control == undefined) {
return;
}
if (this.editor.outlinePass == undefined) {
return;
}
if (remove_outline) {
this.editor.last_selected = this.editor.selected;
this.editor.outlinePass.selectedObjects = [];
this.editor.publishSelect();
}
this.editor.control.detach();
this.editor.activeScene.scene.remove(this.editor.control);
if (remove_outline) this.editor.outlinePass.selectedObjects = [];
}
// TO UPDATE selected objects in the scene might want to add to the scene ...
async setSelectedObject(position: XYZ, rotation: XYZ, scale: XYZ) {
if (this.editor.sceneManager?.selected_objects) {
const object = this.editor.sceneManager?.selected_objects[0];
if (object != undefined || object != null) {
object.position.x = position.x;
object.position.y = position.y;
object.position.z = position.z;
object.rotation.x = THREE.MathUtils.degToRad(rotation.x);
object.rotation.y = THREE.MathUtils.degToRad(rotation.y);
object.rotation.z = THREE.MathUtils.degToRad(rotation.z);
object.scale.x = scale.x;
object.scale.y = scale.y;
object.scale.z = scale.z;
}
}
}
switchCameraView() {
this.editor.camera_person_mode = !this.editor.camera_person_mode;
this.editor.cameraViewControls?.reset();
if (this.editor.cam_obj) {
if (this.editor.camera_person_mode && this.editor.camera) {
this.editor.last_cam_pos.copy(this.editor.camera.position);
this.editor.last_cam_rot.copy(this.editor.camera.rotation);
this.editor.camera.position.copy(this.editor.cam_obj.position);
this.editor.camera.rotation.copy(this.editor.cam_obj.rotation);
if (this.editor.lockControls) {
this.editor.activeScene.scene.add(
this.editor.lockControls.getObject(),
);
}
if (this.editor.cameraViewControls) {
this.editor.cameraViewControls.enabled = true;
}
this.editor.cam_obj.scale.set(0, 0, 0);
this.removeTransformControls();
this.editor.selected = this.editor.cam_obj;
this.editor.publishSelect();
this.editor.updateSelectedUI();
editorState.value = EditorStates.CAMERA_VIEW;
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = false;
});
}
// Make a better solution later but for now this is so that in camera mode when you right click it does not bring up the context menu and allows you to pan.
setTimeout(
() =>
document
.getElementById("letterbox")
?.addEventListener("contextmenu", function (event) {
event.preventDefault();
}),
250,
);
} else if (this.editor.camera) {
this.editor.camera.position.copy(this.editor.last_cam_pos);
this.editor.camera.rotation.copy(this.editor.last_cam_rot);
if (this.editor.lockControls) {
this.editor.activeScene.scene.remove(
this.editor.lockControls.getObject(),
);
}
this.editor.cam_obj.scale.set(1, 1, 1);
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = true;
});
}
hideObjectPanel();
editorState.value = EditorStates.EDIT;
}
}
}
// Returns the "check sum" of the editors selected object.
getSelectedSum(): number {
if (this.editor.sceneManager?.selected_objects === undefined) {
return 0;
}
if (this.editor.sceneManager?.selected_objects.length <= 0) {
return 0;
}
const posCombo =
this.editor.sceneManager?.selected_objects[0].position.x +
this.editor.sceneManager?.selected_objects[0].position.y +
this.editor.sceneManager?.selected_objects[0].position.z;
const rotCombo =
this.editor.sceneManager?.selected_objects[0].rotation.x +
this.editor.sceneManager?.selected_objects[0].rotation.y +
this.editor.sceneManager?.selected_objects[0].rotation.z;
const sclCombo =
this.editor.sceneManager?.selected_objects[0].scale.x +
this.editor.sceneManager?.selected_objects[0].scale.y +
this.editor.sceneManager?.selected_objects[0].scale.z;
return posCombo + rotCombo + sclCombo;
}
/* Will add in the future
A good practice to remove 3D objects from Three.js scenes
function removeObject3D(object3D) {
if (!(object3D instanceof THREE.Object3D)) return false;
// for better memory management and performance
if (object3D.geometry) object3D.geometry.dispose();
if (object3D.material) {
if (object3D.material instanceof Array) {
// for better memory management and performance
object3D.material.forEach(material => material.dispose());
} else {
// for better memory management and performance
object3D.material.dispose();
}
}
object3D.removeFromParent(); // the parent might be the scene or another Object3D, but it is sure to be removed this way
return true;
}
*/
deleteObject(uuid: string) {
const obj = this.scene.get_object_by_uuid(uuid);
if (!obj) {
return
}
this.removeTransformControls();
if (obj.name === this.editor.camera_name) {
return;
}
// Finally remove the object from the scene
this.scene.scene.remove(obj);
obj.traverse(child => {
(child as THREE.Mesh)?.geometry?.dispose()
if (Array.isArray((child as THREE.Mesh).texture)) {
(child as THREE.Mesh).texture.forEach(mat => mat.dispose());
} else if ((child as THREE.Mesh).texture) {
(child as THREE.Mesh).texture.dispose();
}
if (Array.isArray((child as THREE.Mesh).material)) {
(child as THREE.Mesh).material.forEach(mat => mat.dispose());
} else if ((child as THREE.Mesh).material) {
(child as THREE.Mesh).material.dispose();
}
})
if (Array.isArray((obj as THREE.Mesh).texture)) {
(obj as THREE.Mesh).texture.forEach(mat => mat.dispose());
} else if ((obj as THREE.Mesh).texture) {
(obj as THREE.Mesh).texture.dispose();
}
if (Array.isArray((obj as THREE.Mesh).material)) {
(obj as THREE.Mesh).material.forEach(mat => mat.dispose());
} else if ((obj as THREE.Mesh).material) {
(obj as THREE.Mesh).material.dispose();
}
if ((obj as THREE.Mesh).geometry) {
(obj as THREE.Mesh).geometry.dispose()
}
// TODO
// FIXME: Timeline deletion is called twice, as well as a queue event.
// Why is this necessary?
this.editor.timeline.deleteObject(obj);
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.DELETE_OBJECT,
data: {
version: 1,
type: AssetType.OBJECT,
media_id: "",
object_uuid: uuid,
name: "",
} as MediaItem,
});
this.editor.selected = undefined;
this.editor.publishSelect();
hideObjectPanel();
this.editor.timeline.deleteObject(obj);
}
}
@@ -1,239 +0,0 @@
const BoundingFrequencyMasc = [0, 400, 560, 2400, 4800];
const BoundingFrequencyFem = [0, 500, 700, 3000, 6000];
const IndicesFrequencyFemale = [];
const IndicesFrequencyMale = [];
const FFT_SIZE = 1024;
const samplingFrequency = 44100;
for (let m = 0; m < BoundingFrequencyMasc.length; m++) {
IndicesFrequencyMale[m] = Math.round(
((2 * FFT_SIZE) / samplingFrequency) * BoundingFrequencyMasc[m],
);
}
for (let m = 0; m < BoundingFrequencyFem.length; m++) {
IndicesFrequencyFemale[m] = Math.round(
((2 * FFT_SIZE) / samplingFrequency) * BoundingFrequencyFem[m],
);
}
export class LipSync {
constructor(face) {
this.face = face;
this.buffer = null;
this.last_frame = 0;
// WE NEED THIS TO be run at RUNTIME
// we have to modify this for our
// this.ee = this.face.morphTargetDictionary["E"];
// this.oh = this.face.morphTargetDictionary["O"];
// this.ah = this.face.morphTargetDictionary["aa"];
}
// for streaming sound?
start(stream) {
this.audioContext = new AudioContext();
this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
}
// testing only
startFromAudioFile(file) {
if (!this.audioContext) this.audioContext = new AudioContext();
if (!this.userSpeechAnalyzer)
this.userSpeechAnalyzer = this.audioContext.createAnalyser();
this.userSpeechAnalyzer.smoothingTimeConstant = 0.5;
this.userSpeechAnalyzer.fftSize = FFT_SIZE;
if (this.mediaStreamSource) this.mediaStreamSource.stop();
this.audioContext.decodeAudioData(file).then((buffer) => {
this.mediaStreamSource = this.audioContext.createBufferSource();
this.mediaStreamSource.buffer = buffer;
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
this.mediaStreamSource.connect(this.audioContext.destination);
this.mediaStreamSource.start();
// connect the output of mediaStreamSource to the input of userSpeechAnalyzer
this.mediaStreamSource.connect(this.userSpeechAnalyzer);
});
}
startLipSyncFromAudioBuffer(buffer) {
if (!this.audioContext) this.audioContext = new AudioContext();
if (!this.userSpeechAnalyzer)
this.userSpeechAnalyzer = this.audioContext.createAnalyser();
this.userSpeechAnalyzer.smoothingTimeConstant = 0.5;
this.userSpeechAnalyzer.fftSize = FFT_SIZE;
if (this.mediaStreamSource) this.mediaStreamSource.stop();
this.buffer = buffer;
}
destroy() {
this.meter?.shutdown();
this.meter = null;
this.mediaStreamSource?.disconnect();
return this.audioContext?.close().catch(() => {}) || Promise.resolve();
}
update(frame, offset, rendering) {
const frameBuffer = 12;
let pos = frame - offset;
if (Math.floor(pos) <= 1){
this.last_frame = 0;
}
let doPlay = true;
if (
Math.abs(this.last_frame - frame) < frameBuffer - 1 &&
this.last_frame !== 0
) {
doPlay = false;
}
if (doPlay) {
const startTime = pos / 60.0;
const endTime = frameBuffer / 60.0;
this.mediaStreamSource = this.audioContext.createBufferSource();
this.mediaStreamSource.buffer = this.buffer;
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
this.mediaStreamSource.connect(this.audioContext.destination);
this.mediaStreamSource.start(0, startTime, endTime);
this.mediaStreamSource.connect(this.userSpeechAnalyzer);
this.last_frame = frame;
}
if (this.meter) {
const { volume } = this.meter;
let { ah, oh, ee } = this.update2();
if (volume < 0.01) {
ah = 0;
oh = 0;
ee = 0;
}
return { ah: ah, oh: oh, ee: ee };
}
return { ah: 0, oh: 0, ee: 0 };
}
update2() {
function getSensitivityMap(spectrum) {
const sensitivity_threshold = 0.5;
const stPSD = new Float32Array(spectrum.length);
for (let i = 0; i < spectrum.length; i++) {
stPSD[i] = sensitivity_threshold + (spectrum[i] + 20) / 140;
}
return stPSD;
}
const spectrum = new Float32Array(
this.userSpeechAnalyzer.frequencyBinCount,
);
// Populate frequency data for computing frequency intensities
this.userSpeechAnalyzer.getFloatFrequencyData(spectrum); // getByteTimeDomainData gets volumes over the sample time
// Populate time domain for calculating RMS
// userSpeechAnalyzer.getFloatTimeDomainData(spectrum);
// RMS (root mean square) is a better approximation of current input level than peak (just sampling this frame)
// spectrumRMS = getRMS(spectrum);
const sensitivityPerPole = getSensitivityMap(spectrum);
// Lower and higher voices have different frequency domains, so we'll separate and max them
const EnergyBinMasc = new Float32Array(BoundingFrequencyMasc.length);
const EnergyBinFem = new Float32Array(BoundingFrequencyFem.length);
// Masc energy bins (groups of frequency-depending energy)
for (let m = 0; m < BoundingFrequencyMasc.length - 1; m++) {
for (
let j = IndicesFrequencyMale[m];
j <= IndicesFrequencyMale[m + 1];
j++
)
if (sensitivityPerPole[j] > 0)
EnergyBinMasc[m] += sensitivityPerPole[j];
EnergyBinMasc[m] /= IndicesFrequencyMale[m + 1] - IndicesFrequencyMale[m];
}
// Fem energy bin
for (let m = 0; m < BoundingFrequencyFem.length - 1; m++) {
for (
let j = IndicesFrequencyMale[m];
j <= IndicesFrequencyMale[m + 1];
j++
)
if (sensitivityPerPole[j] > 0) EnergyBinFem[m] += sensitivityPerPole[j];
EnergyBinMasc[m] /= IndicesFrequencyMale[m + 1] - IndicesFrequencyMale[m];
EnergyBinFem[m] =
EnergyBinFem[m] /
(IndicesFrequencyFemale[m + 1] - IndicesFrequencyFemale[m]);
}
const oh =
Math.max(EnergyBinFem[1], EnergyBinMasc[1]) > 0.2
? 1 - 2 * Math.max(EnergyBinMasc[2], EnergyBinFem[2])
: (1 - 2 * Math.max(EnergyBinMasc[2], EnergyBinFem[2])) *
5 *
Math.max(EnergyBinMasc[1], EnergyBinFem[1]);
const ah = 3 * Math.max(EnergyBinMasc[3], EnergyBinFem[3]);
const ee =
0.8 *
(Math.max(EnergyBinMasc[1], EnergyBinFem[1]) -
Math.max(EnergyBinMasc[3], EnergyBinFem[3]));
return { oh, ee, ah };
}
static createAudioMeter(audioContext) {
const processor = audioContext.createScriptProcessor(512);
processor.onaudioprocess = (event) => {
const buf = event.inputBuffer.getChannelData(0);
const bufLength = buf.length;
let sum = 0;
let x;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < bufLength; i++) {
x = buf[i];
if (Math.abs(x) >= processor.clipLevel) {
processor.clipping = true;
processor.lastClip = window.performance.now();
}
sum += x * x;
}
const rms = Math.sqrt(sum / bufLength);
processor.volume = Math.max(rms, processor.volume * processor.averaging);
};
processor.clipping = false;
processor.lastClip = 0;
processor.volume = 0;
processor.clipLevel = 0.98;
processor.averaging = 0.95;
processor.clipLag = 750;
processor.connect(audioContext.destination);
processor.checkClipping = () => {
if (!processor.clipping) {
return false;
}
if (processor.lastClip + processor.clipLag < window.performance.now()) {
processor.clipping = false;
}
return processor.clipping;
};
processor.shutdown = () => {
processor.disconnect();
processor.onaudioprocess = null;
};
return processor;
}
}
@@ -1,41 +0,0 @@
import { MediaUploadApi, VideoApi } from "~/Classes/ApiManager";
import {
ProcessStatus,
StreamingProgressResponse,
VideoAudioPreProcessor,
} from "./VideoProcessor/video_audio_preprocessor";
class PreviewVideoGeneration {
mediaUploadAPI: MediaUploadApi;
videoAPI: VideoApi;
videoAndAudioPreProcessor: VideoAudioPreProcessor;
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void;
constructor(
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void,
) {
this.onProgress = onProgress;
this.videoAndAudioPreProcessor = new VideoAudioPreProcessor(onProgress);
this.mediaUploadAPI = new MediaUploadApi();
this.videoAPI = new VideoApi();
}
// Cancel any part of the processing
public async cancel() {}
private async stageOneProcessing() {}
private async stageTwoProcessing() {}
private async processVideo() {
// check if video is cached or not if not jump to 50%
// collect frames and run preprocessing in the background.
// start polling for percentage change and convert and remap to remainint %
}
async clearFrameBuffers() {}
}
export { PreviewVideoGeneration };
@@ -1,97 +0,0 @@
export class MoveAIResult {
bone: string;
is_special: boolean;
x: number;
y: number;
z: number;
flip: boolean;
only_y: boolean;
constructor(bone: string, is_special: boolean = false, x: number = 0, y: number = 0, z: number = 0,
flip:boolean=false, only_y:boolean=false) {
this.bone = bone;
this.is_special = is_special;
this.x = x;
this.y = y;
this.z = z;
this.flip = flip;
this.only_y = only_y;
}
}
export class Retarget {
move_ai_options: { [Key: string]: MoveAIResult }
constructor() {
this.move_ai_options = {
"_1Head.quaternion": new MoveAIResult("mixamorigHead.quaternion"),
"_1Neck.quaternion": new MoveAIResult("mixamorigNeck.quaternion"),
"_Spine.quaternion": new MoveAIResult("mixamorigSpine.quaternion"),
"_1Spine.quaternion": new MoveAIResult("mixamorigSpine1.quaternion"),
"_3Spine.quaternion": new MoveAIResult("mixamorigSpine2.quaternion"),
"_1Hips.quaternion": new MoveAIResult("mixamorigHips.quaternion", true, -90, 0, 0, false, true),
"_1Hips.position": new MoveAIResult("mixamorigHips.position", true, 1, 0.05, 1),
"_1LeftFoot.quaternion": new MoveAIResult("mixamorigLeftFoot.quaternion", true, 0, 0, 0, true),
"_1RightFoot.quaternion": new MoveAIResult("mixamorigRightFoot.quaternion", true, 0, 0, 0, true),
"_1RightShoulder.quaternion": new MoveAIResult("mixamorigRightShoulder.quaternion", true, 0, 0, 0, false),
"_1LeftShoulder.quaternion": new MoveAIResult("mixamorigLeftShoulder.quaternion", true, 0, 0, 0, false),
"_1LeftArm.quaternion": new MoveAIResult("mixamorigLeftArm.quaternion", true, 0, 0, 0, false),
"_1RightArm.quaternion": new MoveAIResult("mixamorigRightArm.quaternion", true, 0, 0, 0, false),
"_1LeftForeArm.quaternion": new MoveAIResult("mixamorigLeftForeArm.quaternion"),
"_1RightForeArm.quaternion": new MoveAIResult("mixamorigRightForeArm.quaternion"),
"_1LeftLeg.quaternion": new MoveAIResult("mixamorigLeftLeg.quaternion", true, 0, 0, 0, true),
"_1RightLeg.quaternion": new MoveAIResult("mixamorigRightLeg.quaternion", true, 0, 0, 0, true),
"_1LeftUpLeg.quaternion": new MoveAIResult("mixamorigLeftUpLeg.quaternion", true, 0, 180, 0),
"_1RightUpLeg.quaternion": new MoveAIResult("mixamorigRightUpLeg.quaternion", true, 0, 180, 0),
"_1LeftHand.quaternion": new MoveAIResult("mixamorigLeftHand.quaternion"),
"_1RightHand.quaternion": new MoveAIResult("mixamorigRightHand.quaternion"),
"_1LeftHandThumb1.quaternion": new MoveAIResult("mixamorigLeftHandThumb1.quaternion"),
"_1LeftHandThumb2.quaternion": new MoveAIResult("mixamorigLeftHandThumb2.quaternion"),
"_1LeftHandThumb3.quaternion": new MoveAIResult("mixamorigLeftHandThumb3.quaternion"),
"_1LeftHandIndex1.quaternion": new MoveAIResult("mixamorigLeftHandIndex1.quaternion"),
"_1LeftHandIndex2.quaternion": new MoveAIResult("mixamorigLeftHandIndex2.quaternion"),
"_1LeftHandIndex3.quaternion": new MoveAIResult("mixamorigLeftHandIndex3.quaternion"),
"_1LeftHandMiddle1.quaternion": new MoveAIResult("mixamorigLeftHandMiddle1.quaternion"),
"_1LeftHandMiddle2.quaternion": new MoveAIResult("mixamorigLeftHandMiddle2.quaternion"),
"_1LeftHandMiddle3.quaternion": new MoveAIResult("mixamorigLeftHandMiddle3.quaternion"),
"_1LeftHandRing1.quaternion": new MoveAIResult("mixamorigLeftHandRing1.quaternion"),
"_1LeftHandRing2.quaternion": new MoveAIResult("mixamorigLeftHandRing2.quaternion"),
"_1LeftHandRing3.quaternion": new MoveAIResult("mixamorigLeftHandRing3.quaternion"),
"_1LeftHandPinky1.quaternion": new MoveAIResult("mixamorigLeftHandPinky1.quaternion"),
"_1LeftHandPinky2.quaternion": new MoveAIResult("mixamorigLeftHandPinky2.quaternion"),
"_1LeftHandPinky3.quaternion": new MoveAIResult("mixamorigLeftHandPinky3.quaternion"),
"_1RightHandThumb1.quaternion": new MoveAIResult("mixamorigRightHandThumb1.quaternion"),
"_1RightHandThumb2.quaternion": new MoveAIResult("mixamorigRightHandThumb2.quaternion"),
"_1RightHandThumb3.quaternion": new MoveAIResult("mixamorigRightHandThumb3.quaternion"),
"_1RightHandIndex1.quaternion": new MoveAIResult("mixamorigRightHandIndex1.quaternion"),
"_1RightHandIndex2.quaternion": new MoveAIResult("mixamorigRightHandIndex2.quaternion"),
"_1RightHandIndex3.quaternion": new MoveAIResult("mixamorigRightHandIndex3.quaternion"),
"_1RightHandMiddle1.quaternion": new MoveAIResult("mixamorigRightHandMiddle1.quaternion"),
"_1RightHandMiddle2.quaternion": new MoveAIResult("mixamorigRightHandMiddle2.quaternion"),
"_1RightHandMiddle3.quaternion": new MoveAIResult("mixamorigRightHandMiddle3.quaternion"),
"_1RightHandRing1.quaternion": new MoveAIResult("mixamorigRightHandRing1.quaternion"),
"_1RightHandRing2.quaternion": new MoveAIResult("mixamorigRightHandRing2.quaternion"),
"_1RightHandRing3.quaternion": new MoveAIResult("mixamorigRightHandRing3.quaternion"),
"_1RightHandPinky1.quaternion": new MoveAIResult("mixamorigRightHandPinky1.quaternion"),
"_1RightHandPinky2.quaternion": new MoveAIResult("mixamorigRightHandPinky2.quaternion"),
"_1RightHandPinky3.quaternion": new MoveAIResult("mixamorigRightHandPinky3.quaternion"),
}
}
retarget(boneName: string): MoveAIResult {
for (const [key, value] of Object.entries(this.move_ai_options)) {
if (boneName == key) {
return value;
}
}
return new MoveAIResult(boneName);
}
}
@@ -1,303 +0,0 @@
import * as THREE from "three";
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
import {
setCameraAspectRatio,
hideEditorLoader,
restoreSceneGenerationMetadata,
showEditorLoader,
globalIPAMediaToken,
} from "../signals";
import Editor from "./editor";
import { getArtStyle } from "~/enums";
import { cameras, selectedCameraId, Camera } from "../signals/camera";
export type EditorInitializeConfig = {
sceneToken: string;
};
// need to move this into the
export class SaveManager {
editor: Editor;
constructor(editor: Editor) {
this.editor = editor;
}
async computeHashForBrowser(data: string): Promise<string> {
// Encode the string data to a Uint8Array
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
// Compute the hash using the SubtleCrypto.digest method
const hashBuffer = await crypto.subtle.digest("SHA-256", encodedData);
// Convert the ArrayBuffer to a hexadecimal string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
public async computeSceneChecksum(): Promise<string> {
const jsonString = await this.checkSumData();
return this.computeHashForBrowser(jsonString);
}
private async checkSumData(): Promise<string> {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const check_sum_data = {
scene: scene_json,
timeline: "",
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
};
const jsonString = JSON.stringify(check_sum_data);
return jsonString;
}
// JSON structure should and can return snapshot
public async saveData({
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
// take json scene and figure out checksum
const jsonString = JSON.stringify(save_data);
return jsonString;
}
public getSceneJson({
sceneGenerationMetadata,
}: {
sceneGenerationMetadata: SceneGenereationMetaData;
}) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = proxyScene.saveToScene(this.editor.version);
console.log(scene_json);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
return save_data;
}
// TODO Move this function into scene manager.
public async saveScene({
sceneTitle,
sceneToken,
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
this.editor.generating_preview = true; // Set this to true to stop control panel from flipping out.
// remove controls when saving scene.
this.editor.utils.removeTransformControls();
showEditorLoader();
const sceneJson = this.getSceneJson({
sceneGenerationMetadata,
});
// TODO turn scene information into and object ...
let sceneThumbnail = undefined;
if (this.editor.renderer) {
const imgData = this.editor.renderer.domElement.toDataURL();
const response = await fetch(imgData); // Fetch the data URL
sceneThumbnail = await response.blob(); // Convert to Blob
}
const result = await this.editor.api_manager.saveSceneState({
saveJson: JSON.stringify(sceneJson),
sceneTitle,
sceneToken,
sceneThumbnail,
});
// this means error going to have to fix the plumbing on this because not using
// the api manager
hideEditorLoader();
this.editor.generating_preview = false; // FIX THIS LATER WITH VICCCCCCCCCCCCCCCTORRRRRRRR
console.debug("Save Scene Result: ", result);
return result; // if this is an empty string it is an error. need to migrate to api manager.
}
private async loadFromJson(scene_json: any) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
await proxyScene.loadFromSceneJson(
scene_json["scene"],
scene_json["skybox"],
scene_json["version"],
);
const camera_data = scene_json["camera_data"];
if (camera_data && this.editor.camera) {
const camera_position: THREE.Vector3 = camera_data["position"];
const camera_rotation: THREE.Euler = camera_data["rotation"];
this.editor.camera.position.copy(camera_position);
this.editor.camera.rotation.copy(camera_rotation);
}
// Restore cameras data
if (scene_json.cameras) {
cameras.value = scene_json.cameras.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
}
// Restore selected camera
if (scene_json.selectedCameraId) {
selectedCameraId.value = scene_json.selectedCameraId;
}
// For Remixing Scenes.
// this calls the signal function to propagate the data to the UI
restoreSceneGenerationMetadata(scene_json);
// these propogate the values into the editor
if (scene_json.globalIpAdapterImageMediaToken) {
// this should be populated right after
globalIPAMediaToken.value = scene_json.globalIpAdapterImageMediaToken;
}
if (scene_json.positivePrompt) {
this.editor.positive_prompt = scene_json.positivePrompt;
}
if (scene_json.negativePrompt) {
this.editor.negative_prompt = scene_json.negativePrompt;
}
if (scene_json.artisticStyle) {
this.editor.art_style = getArtStyle(scene_json["artisticStyle"]);
}
if (scene_json.cameraAspectRatio) {
//editor propagation
this.editor.changeRenderCameraAspectRatio(scene_json.cameraAspectRatio);
//signal propagation
setCameraAspectRatio(scene_json.cameraAspectRatio);
}
this.editor.version = scene_json["version"];
this.editor.cam_obj = this.editor.activeScene.get_object_by_name(
this.editor.camera_name,
);
this.editor.cam_obj?.layers.set(1);
this.editor.cam_obj?.children.forEach((child) => {
child.layers.set(1);
});
console.debug("Loading Timeline", scene_json["timeline"]);
this.editor.timeline.checkEditorCanPlay();
}
public async loadCache(cacheJson: string) {
showEditorLoader();
const scene_json = JSON.parse(cacheJson);
await this.loadFromJson(scene_json);
hideEditorLoader();
this.editor.timeline.scrub({ currentTime: 0 });
}
// TODO Refactor remove editor.
public async loadScene(scene_media_token: string) {
showEditorLoader();
this.editor.current_scene_media_token = scene_media_token;
const scene_json = await this.editor.api_manager
.loadSceneState(this.editor.current_scene_media_token)
.catch((err) => {
hideEditorLoader();
throw err;
});
await this.loadFromJson(scene_json);
hideEditorLoader();
// TODO figure out if this is a bug.
this.editor.timeline.scrub({ currentTime: 0 });
}
}
@@ -1,577 +0,0 @@
import * as THREE from "three";
import {
IconDefinition,
faCamera,
faCube,
faPerson,
} from "@fortawesome/pro-solid-svg-icons";
import Scene from "./scene";
import { MouseControls } from "./keybinds_controls";
import { ClipGroup } from "~/enums";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import { AssetType } from "~/enums";
import {
CommandInputTypes,
CreationSceneItem,
DeletionData,
DeletionSceneItem,
ICommand,
SceneState,
TransformData,
TransformSceneItem,
UserData,
UserDataSceneItem,
} from "./Commands";
export type SceneObject = {
id: string;
icon: IconDefinition;
name: string;
type: string;
visible: boolean;
locked: boolean;
};
export interface SceneManagerAPI {
create(media_token: string, name: string, position: THREE.Vector3): void;
retrieve(object_uuid: string): void;
delete(object_uuid: string): void;
update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
): void;
undo(): void;
redo(): void;
selected(): void;
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
onMouseMove(event: MouseEvent): void;
onMouseClick(): void;
onKeyDown(event: KeyboardEvent): void;
onMouseDown(event: MouseEvent): void;
onMouseUp(event: MouseEvent): void;
select_object(id: string): void;
}
type UpdateOutliner = () => void;
export class SceneManager implements SceneManagerAPI {
scene: Scene;
mouse_controls: MouseControls;
version: number;
selected_objects: THREE.Object3D[] | undefined;
private updateOutliner: UpdateOutliner;
public undoStack: ICommand<CommandInputTypes>[] = [];
private undoIndex: number = 0;
private lastSceneState: SceneState;
private copiedObject: THREE.Object3D | undefined;
private is_character: Function;
private devMode: boolean;
constructor(
version: number,
mouse_controls: MouseControls,
scene: Scene,
devMode: boolean = false,
updateOutliner: UpdateOutliner,
is_character: Function,
) {
this.mouse_controls = mouse_controls;
this.scene = scene;
this.version = version;
this.lastSceneState = this.getSceneState();
this.updateOutliner = updateOutliner;
this.is_character = is_character;
this.devMode = devMode;
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseClick = this.onMouseClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
}
public attachEventListeners() {
if (!this.devMode) {
return;
}
window.addEventListener("mousemove", this.onMouseMove, false);
window.addEventListener("click", this.onMouseClick, false);
window.addEventListener("keydown", this.onKeyDown, false);
window.addEventListener("mousedown", this.onMouseDown, false);
window.addEventListener("mouseup", this.onMouseUp, false);
}
public detachEventListeners() {
if (!this.devMode) {
return;
}
window.removeEventListener("mousemove", this.onMouseMove, false);
window.removeEventListener("click", this.onMouseClick, false);
window.removeEventListener("keydown", this.onKeyDown, false);
window.removeEventListener("mousedown", this.onMouseDown, false);
window.removeEventListener("mouseup", this.onMouseUp, false);
}
public async undo() {
if (this.undoIndex >= this.undoStack.length) {
return;
}
this.undoIndex += 1;
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
await undoCommand?.undo();
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
public async redo() {
if (this.undoIndex <= 0) {
return;
}
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
await undoCommand?.redo();
this.undoIndex -= 1;
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
// Drop any redo-future before pushing a fresh command so the new action
// replaces the branch of history the user had undone past.
private dropRedoHistory() {
if (this.undoIndex > 0) {
this.undoStack = this.undoStack.slice(
0,
this.undoStack.length - this.undoIndex,
);
this.undoIndex = 0;
}
}
public async create(
media_token: string,
name: string,
position: THREE.Vector3,
): Promise<THREE.Object3D<THREE.Object3DEventMap> | undefined> {
if (media_token.includes("SKY::")) {
const token = media_token.replace("SKY::", "");
this.scene.updateSkybox(token);
} else if (media_token !== "Parim") {
// Support direct image/video URL pastes via the Image::<url> scheme
if (media_token.startsWith("Image::")) {
const url = media_token.replace("Image::", "");
const obj = await this.scene.loadObjectFromUrl(url, position);
if (obj) {
obj.name = name;
obj.userData["name"] = name;
}
return obj as THREE.Object3D<THREE.Object3DEventMap>;
}
const obj = await this.scene.loadObject(
media_token,
name,
true,
position,
this.version,
);
if (obj) {
obj.name = name;
obj.userData["name"] = name;
}
return obj as THREE.Object3D<THREE.Object3DEventMap> | undefined;
} else {
return this.scene.instantiate(name, position);
}
}
updateSkybox(media_id: string) {
this.scene.updateSkybox(media_id);
}
/* NEVER CALL THIS INTERNALLY */
public render_outliner(timeline_characters: { [key: string]: ClipGroup }) {
// needs timeline_characters to render favicons.
// Not permanent just in place until we have multi object select ability.
const selected_item = this.selected();
const signal_items: SceneObject[] = [];
this.scene.scene.children.forEach((child) => {
const converted = this.convert_object(child, timeline_characters);
if (converted.name !== "") {
signal_items.push(converted);
}
});
const outlinerState = {
selectedItem: selected_item,
items: signal_items,
};
return outlinerState;
}
public async retrieve(object_uuid: string) {
return this.scene.get_object_by_uuid(object_uuid);
}
public async update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
) {
const object = await this.retrieve(object_uuid);
if (object) {
object.position.copy(position);
object.rotation.copy(rotation);
object.scale.copy(scale);
}
}
public async delete(object_uuid: string) {
// Deletes an object.
this.mouse_controls.deleteObject(object_uuid);
}
public async double_click() {
this.mouse_controls.focus();
}
public async hideObject(object_uuid: string) {
const object = await this.retrieve(object_uuid);
if (object?.visible !== undefined) {
object.visible = !object.visible;
object.userData["visible"] = object.visible;
}
}
public selected() {
let selected_item = null;
if (this.selected_objects && this.selected_objects.length > 0) {
selected_item = this.selected_objects[0];
}
if (selected_item) {
return this.convert_object(selected_item, {});
}
return null;
}
public select_object(id: string) {
const object = this.scene.get_object_by_uuid(id);
if (object) {
this.mouse_controls.selected = [object];
this.mouse_controls.selectObject(object);
}
}
// Converts a 3d object to signal item format.
private convert_object(
object: THREE.Object3D,
timeline_characters: { [key: string]: ClipGroup },
) {
let faicon = faCube;
let name = object.name;
if (object.name == "::CAM::") {
faicon = faCamera;
name = "Camera";
} else if (object.uuid in timeline_characters) {
faicon = faPerson;
}
let locked = object.userData["locked"];
if (locked == undefined) {
locked = false;
}
return {
id: object.uuid,
icon: faicon,
name: name.charAt(0).toUpperCase() + name.slice(1),
type: object.type,
visible: object.visible,
locked: object.userData["locked"],
};
}
public async copy() {
const object = this.mouse_controls.selected?.at(0);
if (object !== undefined && object.name !== "::CAM::") {
this.copiedObject = object;
}
}
public async paste() {
if (this.copiedObject && this.copiedObject.name != "::CAM::") {
const userdata = this.copiedObject.userData;
const position = this.copiedObject.position.clone();
const rotation = this.copiedObject.rotation.clone();
const scale = this.copiedObject.scale.clone();
const media_id = userdata["media_id"];
const color = userdata["color"];
const name = this.copiedObject.name;
const wasCharacter: boolean = !!this.copiedObject.userData.isCharacter;
const obj = await this.create(media_id, name, position);
if (!obj) {
return;
}
this.scene.setColor(obj.uuid, color);
obj.position.copy(position.add(new THREE.Vector3(0.5, 0.0, 0.5)));
obj.rotation.copy(rotation);
obj.scale.copy(scale);
// Preserve character stuff on pasted object
if (wasCharacter) {
obj.userData.isCharacter = true;
}
this.mouse_controls.selectObject(obj);
this.updateOutliner();
if (wasCharacter) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_CHARACTER_ID,
data: {
version: 1,
type: AssetType.CHARACTER,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
} else {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.ADD_OBJECT,
data: {
version: 1,
type: AssetType.OBJECT,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
}
await this.copy();
await this.add_creation_undostack(obj);
}
}
public onMouseMove(event: MouseEvent) {
this.mouse_controls.onMouseMove(event);
}
public onMouseClick() {
this.mouse_controls.onMouseClick();
}
public onKeyDown(event: KeyboardEvent) {
this.mouse_controls.onkeydown(event);
}
public onMouseDown(event: MouseEvent) {
this.mouse_controls.onMouseDown(event);
}
public onMouseUp(event: MouseEvent) {
this.mouse_controls.onMouseUp(event);
this.check_scene_for_updates();
}
private getSceneState(): SceneState {
const scene_objects: string[] = [];
const scene_names: string[] = [];
const scene_positions: string[] = [];
const scene_userdata: string[] = [];
const scene_rotations: string[] = [];
const scene_scales: string[] = [];
this.scene.scene.children.forEach((child) => {
if (child.name != "") {
scene_objects.push(child.uuid);
scene_names.push(child.name);
scene_userdata.push(JSON.stringify(child.userData));
scene_positions.push(JSON.stringify(child.position.clone()));
scene_rotations.push(JSON.stringify(child.rotation.clone()));
scene_scales.push(JSON.stringify(child.scale.clone()));
}
});
return {
scene_objects: scene_objects,
scene_names: scene_names,
scene_userdata: scene_userdata,
scene_positions: scene_positions,
scene_rotations: scene_rotations,
scene_scales: scene_scales,
};
}
private is_creation(sceneState: SceneState): string {
let resp = "";
sceneState.scene_objects.forEach((object) => {
if (!this.lastSceneState.scene_objects.includes(object)) {
resp = object;
return resp;
}
});
return resp;
}
private is_deletion(sceneState: SceneState): string {
let resp = "";
this.lastSceneState.scene_objects.forEach((object) => {
if (sceneState.scene_objects.includes(object) == false) {
resp = object;
return resp;
}
});
return resp;
}
private is_userdata(sceneState: SceneState): string {
let resp = "";
this.lastSceneState.scene_userdata.forEach((object) => {
if (sceneState.scene_userdata.includes(object) == false) {
const respIdx = this.lastSceneState.scene_userdata.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
return resp;
}
private is_transformation(sceneState: SceneState): string {
let resp = "";
if (resp === "") {
sceneState.scene_positions.forEach((object) => {
if (this.lastSceneState.scene_positions.includes(object) == false) {
const respIdx = sceneState.scene_positions.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
if (resp === "") {
sceneState.scene_rotations.forEach((object) => {
if (this.lastSceneState.scene_rotations.includes(object) == false) {
const respIdx = sceneState.scene_rotations.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
if (resp === "") {
sceneState.scene_scales.forEach((object) => {
if (this.lastSceneState.scene_scales.includes(object) == false) {
const respIdx = sceneState.scene_scales.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
return resp;
}
public async add_creation_undostack(object: THREE.Object3D) {
this.dropRedoHistory();
this.undoStack.push(
new CreationSceneItem(this, {
object_uuid: object.uuid,
name: object.name,
position: object.position.clone(),
userdata: object.userData,
}),
);
this.lastSceneState = this.getSceneState();
}
/* Can be called Internally or Externally for undo redo stack */
// This function allows you to capture transform states and occurs on mouse up.
public async check_scene_for_updates() {
const sceneState = this.getSceneState();
if (JSON.stringify(this.lastSceneState) !== JSON.stringify(sceneState)) {
// Add Scene state for undo redo here.
const is_deleted = this.is_deletion(sceneState);
const is_transform = this.is_transformation(sceneState);
const is_userdata = this.is_userdata(sceneState);
this.dropRedoHistory();
this.undoStack = this.undoStack.slice(-16);
if (is_deleted != "") {
const objectIndex =
this.lastSceneState.scene_objects.indexOf(is_deleted);
const name = this.lastSceneState.scene_names[objectIndex];
const position = this.lastSceneState.scene_positions[objectIndex];
const userdata = JSON.parse(
this.lastSceneState.scene_userdata[objectIndex],
);
const undoPush: DeletionData = {
object_uuid: is_deleted,
name: name,
position: JSON.parse(position),
userdata: userdata,
};
this.undoStack.push(new DeletionSceneItem(this, undoPush));
} else if (is_userdata != "") {
const objectIndex = sceneState.scene_objects.indexOf(is_userdata);
const startUserdata = JSON.parse(
this.lastSceneState.scene_userdata[objectIndex],
);
const userdata = JSON.parse(sceneState.scene_userdata[objectIndex]);
const endPush: UserData = {
object_uuid: is_userdata,
color: userdata["color"],
locked: userdata["locked"],
visible: userdata["visible"],
};
const startPush: UserData = {
object_uuid: is_userdata,
color: startUserdata["color"],
locked: startUserdata["locked"],
visible: startUserdata["visible"],
};
this.undoStack.push(new UserDataSceneItem(this, startPush, endPush));
} else if (is_transform != "") {
const objectIndexStart =
this.lastSceneState.scene_objects.indexOf(is_transform);
if (
this.lastSceneState.scene_positions[objectIndexStart] !== undefined
) {
const objectIndexEnd = sceneState.scene_objects.indexOf(is_transform);
const startPush: TransformData = {
object_uuid: is_transform,
position: JSON.parse(
this.lastSceneState.scene_positions[objectIndexStart],
),
rotation: JSON.parse(
this.lastSceneState.scene_rotations[objectIndexStart],
),
scale: JSON.parse(
this.lastSceneState.scene_scales[objectIndexStart],
),
};
const endPush: TransformData = {
object_uuid: is_transform,
position: JSON.parse(sceneState.scene_positions[objectIndexEnd]),
rotation: JSON.parse(sceneState.scene_rotations[objectIndexEnd]),
scale: JSON.parse(sceneState.scene_scales[objectIndexEnd]),
};
this.undoStack.push(new TransformSceneItem(this, startPush, endPush));
}
}
}
this.lastSceneState = sceneState;
}
}
@@ -1,950 +0,0 @@
import * as THREE from "three";
import { ClipUI } from "../clips/clip_ui";
import Scene from "./scene.js";
import AudioEngine from "./Engines/audio_engine";
import TransformEngine from "./Engines/transform_engine";
import LipSyncEngine from "./Engines/lip_sync_engine";
import Queue, {
UnionedActionTypes,
UnionedDataTypes,
} from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "../Queue/QueueNames";
import { toEngineActions } from "../Queue/toEngineActions";
import { fromEngineActions } from "../Queue/fromEngineActions";
import { ClipGroup, ClipType, AssetType } from "~/enums";
import { CameraAspectRatio, MediaFileType } from "~/pages/PageEnigma/enums";
import { Keyframe, MediaItem, UpdateTime } from "~/pages/PageEnigma/models";
import Editor from "~/pages/PageEnigma/Editor/editor";
import EmotionEngine from "./Engines/emotion_engine";
import { IGenerationOptions } from "~/pages/PageEnigma/models/generationOptions";
import { Vector3 } from "three";
import { filmLength, outlinerState } from "../signals";
import { CharacterAnimationEngine } from "./Engines/CharacterAnimationEngine";
import { SplatMesh } from "@sparkjsdev/spark";
export class TimeLine {
editorEngine: Editor;
timeline_items: ClipUI[];
timeline_limit: number;
absolute_end: number;
scrubber_frame_position: number;
is_playing: boolean;
is_repeating: boolean = true;
// plays audio
audio_engine: AudioEngine;
// key framing
transform_engine: TransformEngine;
// animation engine
animation_engine: CharacterAnimationEngine;
// lip sync engine
lipSync_engine: LipSyncEngine;
// emotion engine
emotion_engine: EmotionEngine;
// characters
characters: { [key: string]: ClipGroup };
scene: Scene;
camera: THREE.Camera | null;
mouse: THREE.Vector2 | undefined;
current_time: number;
camera_name: string;
// ensure that the elements are loaded first.
private debounce_generate_video: boolean;
constructor(
editor: Editor,
audio_engine: AudioEngine,
transform_engine: TransformEngine,
lipsync_engine: LipSyncEngine,
animation_engine: CharacterAnimationEngine,
emotion_engine: EmotionEngine,
scene: Scene,
camera: THREE.Camera | null,
mouse: THREE.Vector2 | undefined,
camera_name: string,
) {
this.editorEngine = editor;
this.timeline_items = [];
this.characters = {};
this.absolute_end = 60 * 12;
this.timeline_limit = this.getTimelineEnd();
this.camera = camera;
this.mouse = mouse;
this.is_playing = false;
this.scrubber_frame_position = 0; // in frames into the tl
// this will be used to play the audio clips
this.audio_engine = audio_engine;
this.transform_engine = transform_engine;
this.lipSync_engine = lipsync_engine;
this.animation_engine = animation_engine;
this.emotion_engine = emotion_engine;
this.scene = scene;
this.debounce_generate_video = false;
Queue.subscribe(
QueueNames.TO_ENGINE,
"engine",
this.handleTimelineActions.bind(this),
);
this.current_time = 0;
this.camera_name = camera_name;
}
public async updateUI() {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_TIME_LINE,
data: this.timeline_items,
});
}
public async pushEvent(action: fromEngineActions, data: UnionedDataTypes) {
//this.current_time += 0.75;
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_TIME,
data: data,
});
}
public isCharacter(uuid: string): boolean {
this.timeline_items.forEach((clip) => {
if (clip.group == ClipGroup.CHARACTER)
this.characters[clip.object_uuid] = ClipGroup.CHARACTER;
});
let result: boolean = false;
for (const key in this.characters) {
if (key === uuid) {
result = true;
return true;
}
}
return result;
}
public async handleTimelineActions(data: {
action: UnionedActionTypes;
data: UnionedDataTypes;
}) {
const action = data.action;
switch (action) {
case toEngineActions.ADD_KEYFRAME:
await this.addKeyFrame(data.data as Keyframe);
break;
case toEngineActions.UPDATE_KEYFRAME:
await this.updateKeyFrame(data.data as Keyframe);
break;
case toEngineActions.DELETE_KEYFRAME:
await this.deleteKeyFrame(data.data as Keyframe);
break;
case toEngineActions.ADD_CLIP:
await this.addClip(data.data as ClipUI);
break;
case toEngineActions.DELETE_CLIP:
await this.deleteClip(data.data as ClipUI);
break;
case toEngineActions.UPDATE_CLIP:
await this.updateClip(data.data as ClipUI);
break;
case toEngineActions.UPDATE_TIME:
await this.scrub(data.data as UpdateTime);
break;
case toEngineActions.MUTE:
await this.mute(data.data as ClipUI, false);
break;
case toEngineActions.UNMUTE:
await this.mute(data.data as ClipUI, true);
break;
// Create operations
case toEngineActions.ADD_CHARACTER: {
const newObject = await this.addCharacter(data.data as MediaItem);
if (newObject)
this.queueNewObjectMessage(
newObject,
data.data as MediaItem,
AssetType.CHARACTER,
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ADD_OBJECT: {
const newObject = await this.addObject(data.data as MediaItem);
if (newObject)
this.queueNewObjectMessage(
newObject,
data.data as MediaItem,
newObject.type as AssetType
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ADD_SHAPE: {
const newShape = await this.addShape(data.data as MediaItem);
this.queueNewObjectMessage(
newShape,
data.data as MediaItem,
AssetType.SHAPE,
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ENTER_PREVIEW_STATE:
await this.editorEngine.switchPreview();
break;
case toEngineActions.ENTER_EDIT_STATE:
this.editorEngine.switchEdit();
break;
case toEngineActions.TOGGLE_CAMERA_STATE:
this.editorEngine.switchCameraView();
break;
case toEngineActions.TOGGLE_REPEATING:
this.is_repeating = !this.is_repeating;
break;
case toEngineActions.REFRESH_PREVIEW:
if (this.editorEngine.switchPreviewToggle) {
await this.editorEngine.generateFrame();
}
break;
case toEngineActions.GENERATE_VIDEO: {
console.log("Calling Generate Video");
// debounce generate video ...
if (this.debounce_generate_video == false) {
this.debounce_generate_video = true;
const options = data.data; // super overloaded talk to the devs about this. TODO... refactor
// pass this in ... rather than doing it implicitly ...
this.editorEngine.generation_options = options as IGenerationOptions;
await this.editorEngine.generateVideo();
this.debounce_generate_video = false;
}
console.log("Generate Video Press Event");
break;
}
case toEngineActions.CHANGE_CAMERA_ASPECT_RATIO: {
this.editorEngine.changeRenderCameraAspectRatio(
data.data as CameraAspectRatio,
);
break;
}
default:
console.log("Action Not Wired", action);
}
}
public async addCharacter(data: MediaItem) {
const media_id = data.media_id;
const name = data.name;
const pos = this.getPos();
const new_data = { ...data };
console.debug('Adding to engine scene manager: ', media_id, name, pos);
const obj = await this.editorEngine.sceneManager?.create(
media_id,
name,
pos,
);
if (obj) {
obj.userData["name"] = name;
obj.name = name;
obj.position.copy(pos);
obj.userData.isCharacter = true;
console.debug("Added character object to timeline; object: ", obj);
// TODO(bt,2025-01-28): Hack for debugging only. Remove soon.
(window as any).lastCharacter = obj;
// TODO(bt,2025-01-28): Hack for debugging only. Remove soon.
const lastRig = obj.children[0];
if (!!lastRig && lastRig.name.includes("gamerig")) {
(window as any).lastRig = lastRig;
}
const object_uuid = obj.uuid;
this.characters[object_uuid] = ClipGroup.CHARACTER; // TODO: Create a class to make the idea of a character.
new_data["object_uuid"] = object_uuid;
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_CHARACTER_ID,
data: new_data,
});
this.addPlayableClip(
new ClipUI(
data.version,
ClipType.FAKE,
ClipGroup.CHARACTER,
"Default",
media_id,
obj.uuid,
obj.uuid,
name,
0,
0,
0,
obj.userData["media_file_type"],
),
);
await this.editorEngine.sceneManager?.add_creation_undostack(obj);
}
return obj;
}
queueNewObjectMessage(
item: THREE.Object3D<THREE.Object3DEventMap>,
data: MediaItem,
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE | AssetType.SPLAT,
) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.ADD_OBJECT,
data: {
media_id: data.media_id,
type: asset_type,
name: item.name,
object_uuid: item.uuid,
version: 1,
} as MediaItem,
});
// this.addPlayableClip(
// new ClipUI(
// data["version"],
// ClipType.FAKE,
// ClipGroup.OBJECT,
// "Default",
// data.media_id,
// item.uuid,
// item.uuid,
// item.name,
// 0,
// 0,
// 0,
// this.scene.get_object_by_uuid(item.uuid)?.userData[
// "media_file_type"
// ],
// ),
// );
}
public getPos() {
this.editorEngine.utils.removeTransformControls(true);
const raycaster = new THREE.Raycaster();
raycaster.layers.enable(0);
raycaster.layers.enable(1);
if (this.editorEngine.mouse && this.camera) {
raycaster.setFromCamera(this.editorEngine.mouse, this.camera);
const intersects = raycaster.intersectObjects(
this.scene.scene.children,
true,
);
if (intersects.length > 0) {
return intersects[0].point;
}
}
return new THREE.Vector3(0, 0, 0);
}
public async addObject(data: MediaItem & { position?: { x: number; y: number; z: number } }) {
const dataPos = data.position;
const pos = dataPos
? new THREE.Vector3(dataPos.x, dataPos.y, dataPos.z)
: this.getPos();
const media_id = data.media_id;
const name = data.name;
const obj = await this.editorEngine.sceneManager?.create(
media_id,
name,
pos,
);
if (obj) {
obj.userData["name"] = name;
obj.name = name;
obj.position.copy(pos);
await this.editorEngine.sceneManager?.add_creation_undostack(obj);
}
return obj;
}
// This method is for local dev testing of objects
// and bypasses the queue system, that's kinda redundant anyway
public addLocalSplat(fileBytes: ArrayBuffer | Uint8Array, flipVertical: boolean = false) {
const splatMesh = new SplatMesh({ fileBytes: fileBytes });
splatMesh.name = `{SplatMesh-${Date.now()}}`;
if (flipVertical) {
splatMesh.rotateX(Math.PI);
}
this.editorEngine.sceneManager?.scene.scene.add(splatMesh);
}
public async addShape(data: MediaItem) {
const pos = this.getPos();
const parim = await this.editorEngine.create_parim(data.media_id, pos);
await this.editorEngine.sceneManager?.add_creation_undostack(parim);
return parim;
}
public async addKeyFrame(data: Keyframe) {
// KeyFrame Object
// version: number;
// clip_uuid: string;
// group: ClipGroup;
// object_uuid?: string;
// offset: number;
// position: XYZ
// rotation: XYZ;
// scale: XYZ;
// selected?: boolean;
const data_json = data;
const uuid = data_json.object_uuid;
const keyframe_uuid = data_json.keyframe_uuid;
let object_name = this.scene.get_object_by_uuid(uuid)?.name;
if (object_name === undefined) {
object_name = "undefined";
}
this.transform_engine.addFrame(
uuid,
data_json.offset,
data_json.position as Vector3,
data_json.rotation as Vector3,
data_json.scale as Vector3,
data_json.offset,
data_json.keyframe_uuid,
);
await this.addPlayableClip(
new ClipUI(
data_json["version"],
ClipType.TRANSFORM,
data_json["group"],
object_name,
"",
keyframe_uuid,
uuid,
object_name,
0,
data_json["offset"],
data_json["offset"],
MediaFileType.None,
),
);
const point = this.scene.createPoint(
data_json.position as Vector3,
data_json.rotation as Vector3,
data_json.scale as Vector3,
data_json.keyframe_uuid,
);
if (this.editorEngine.camera_person_mode) {
point.visible = false;
}
this.checkEditorCanPlay();
}
public checkEditorCanPlay() {
this.editorEngine.can_playback = this.getLastClipEnd() > 1;
this.editorEngine.updateSelectedUI();
}
// This method is NOT responsible for deletion of the object from scene or other editor parts
// It is only responsible for deleting the object from the timeline and its corresponding elements
public deleteObject(object: THREE.Object3D<THREE.Object3DEventMap>) {
if (!object) {
return
}
if (object.name === this.camera_name) {
return;
}
const object_uuid = object.uuid;
this.timeline_items.forEach((element) => {
if (
element.type == ClipType.TRANSFORM &&
element.object_uuid == object_uuid
) {
this.scene.deletePoint(element.clip_uuid);
}
});
// TODO: In the future object should have relations that can be deleted inside the engines
// and the engines handle the deletion of objects.
// Character Animation Engine already does this.
this.timeline_items = this.timeline_items.filter(
(element) => element.object_uuid !== object_uuid,
);
this.animation_engine.removeCharacter(object);
// Update react land here.
}
public async addClip(data: ClipUI) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const name = data.name;
const group = data.group;
const version = 1;
const type = data.type;
const offset = data.offset;
const end_offset = data.length + offset;
const object = this.scene.get_object_by_uuid(object_uuid);
const object_name =
object?.name ?? "undefined";
const clip_uuid = data.clip_uuid;
switch (type) {
case "animation":
await this.animation_engine.addCharacterAnimationMedia(object!, media_id, data);
this.animation_engine.evaluate(this.current_time, this.timeline_limit);
break;
case "transform":
this.transform_engine.loadObject(object_uuid, data.length);
break;
case "expression":
this.emotion_engine.loadClip(object_uuid, media_id);
break;
case "audio":
if (group === "character") {
this.lipSync_engine.load_object(object_uuid, media_id);
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
ClipType.AUDIO,
ClipGroup.CHARACTER,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset,
0, // length
this.scene.get_object_by_uuid(object_uuid)?.userData[
"media_file_type"
],
),
);
return;
} else {
this.audio_engine.loadClip(media_id);
}
break;
}
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
type,
group,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset, // length
0,
this.scene.get_object_by_uuid(object_uuid)?.userData["media_file_type"],
),
);
this.checkEditorCanPlay();
}
public async addSelfAnimationClip(
data: ClipUI,
animation_clip: THREE.AnimationClip,
) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const name = data.name;
const group = data.group;
const version = 1;
const type = data.type;
const offset = data.offset;
const end_offset = data.length + offset;
const object = this.scene.get_object_by_uuid(object_uuid);
const object_name =
object?.name ?? "undefined";
const clip_uuid = data.clip_uuid;
this.animation_engine.addCharacterAnimation(object!, animation_clip, data);
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
type,
group,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset, // length
0,
this.scene.get_object_by_uuid(object_uuid)?.userData["media_file_type"],
),
);
this.checkEditorCanPlay();
this.updateUI();
}
public async deleteKeyFrame(data: Keyframe) {
const keyframe_uuid = data.keyframe_uuid;
const object_uuid = data.object_uuid;
this.transform_engine.clips[object_uuid].removeKeyframe(keyframe_uuid);
this.scene.deletePoint(keyframe_uuid);
for (const element of this.timeline_items) {
if (
element.clip_uuid === keyframe_uuid &&
element.object_uuid === object_uuid
) {
this.timeline_items = this.timeline_items.filter(
(element) =>
!(
element.clip_uuid === keyframe_uuid &&
element.object_uuid === object_uuid
),
);
break;
}
}
this.checkEditorCanPlay();
}
public async updateKeyFrame(data: Keyframe) {
const keyframe_uuid = data.keyframe_uuid;
const keyframe_offset = data.offset;
const object_uuid = data.object_uuid;
const keyframe_pos = data.position;
const keyframe_rot = data.rotation;
const keyframe_scl = data.scale;
this.transform_engine.clips[object_uuid].setOffset(
keyframe_uuid,
keyframe_offset,
);
this.transform_engine.clips[object_uuid].setTransform(
keyframe_uuid,
keyframe_pos as Vector3,
keyframe_rot as Vector3,
keyframe_scl as Vector3,
);
this.scene.updatePoint(
keyframe_uuid,
keyframe_pos as Vector3,
keyframe_rot as Vector3,
);
this.checkEditorCanPlay();
}
public async updateClip(data: ClipUI) {
// only length and offset changes here.
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const offset = data.offset;
const length = data.length + offset;
const clip_uuid = data.clip_uuid;
for (const element of this.timeline_items) {
if (
element.media_id === media_id &&
element.object_uuid === object_uuid &&
element.clip_uuid == clip_uuid
) {
element.length = length;
element.offset = offset;
}
}
this.checkEditorCanPlay();
}
public async deleteClip(data: ClipUI) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const clip_uuid = data.clip_uuid;
// Remove the clip from the items array
for (let i = 0; i < this.timeline_items.length; i++) {
const element = this.timeline_items[i];
if (
element.media_id === media_id &&
element.object_uuid === object_uuid &&
element.clip_uuid == clip_uuid
) {
this.timeline_items.splice(i, 1);
break;
}
}
// Once the clip is removed, we need to make sure the engines aren't tracking it either
// TODO: Ideally the engines should just handle this individually and we check them based on clip type/group
// This way we won't need to iterate over all timeline items
if (data.type === ClipType.ANIMATION) {
const object = this.scene.get_object_by_uuid(object_uuid)!;
this.animation_engine.removeAnimation(object, data)
}
this.checkEditorCanPlay();
}
public async mute(data: ClipUI, isMute: boolean) {
this.timeline_items.forEach((element) => {
if (element.group === data.group) {
element.should_play = isMute;
}
});
}
public async addPlayableClip(clip: ClipUI): Promise<void> {
this.timeline_items.push(clip);
}
public async scrub(data: UpdateTime): Promise<void> {
if (this.is_playing) {
return;
}
const value = Math.floor(data.currentTime);
await this.setScrubberPosition(value);
this.current_time = value;
await this.update();
if (this.editorEngine.switchPreviewToggle) {
await this.editorEngine.generateFrame();
}
}
// public streaming events into the timeline from
public async setScrubberPosition(offset: number) {
this.scrubber_frame_position = offset; // in ms
}
// should play from the clip that is closest to the to scrubber
public async play(): Promise<void> {
console.log(`Play - Starting Timeline`);
this.is_playing = true;
}
public async resetScene() {
for (const element of this.timeline_items) {
if (element.type === ClipType.TRANSFORM) {
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (object && this.transform_engine.clips[element.object_uuid]) {
this.transform_engine.clips[element.object_uuid].reset(object);
}
} else if (
element.type === ClipType.AUDIO &&
element.group !== ClipGroup.CHARACTER
) {
this.audio_engine.loadClip(element.media_id);
this.audio_engine.stopClip(element.media_id);
} else if (element.type === ClipType.ANIMATION) {
this.animation_engine.stop();
} else if (
element.type === ClipType.AUDIO &&
element.group === ClipGroup.CHARACTER
) {
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].stop();
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].reset();
} else if (element.type === ClipType.EXPRESSION) {
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (object)
this.emotion_engine.clips[
element.object_uuid + element.media_id
].reset(object);
}
}
}
public getLastClipEnd(): number {
let longest = 0;
for (const element of this.timeline_items) {
if (longest < element.length) {
longest = element.length;
}
}
return longest;
}
public getTimelineEnd(): number {
return filmLength.peek() * 1000;
}
// called by the editor update loop on each frame
public async update(
isRendering = false,
delta_time: number = 0,
): Promise<boolean> {
//if (this.is_playing === false) return; // start and stop
this.timeline_limit = this.getTimelineEnd();
if (this.is_playing) {
this.current_time += delta_time * 1000;
this.pushEvent(fromEngineActions.UPDATE_TIME, {
currentTime: this.current_time,
});
this.scrubber_frame_position = this.current_time;
}
if (this.scrubber_frame_position <= 0) {
for (const video_plane of this.scene.video_planes) {
video_plane.currentTime = 0;
}
await this.resetScene();
}
for (const video_plane of this.scene.video_planes) {
// Caps to 10fps so that the buffering issue is solved and it plays i am not sure how to fix this.
// TODO: Fix buffering and make 30 fps.
const fixedPoint = 1;
let pb = parseFloat(
(this.scrubber_frame_position / this.editorEngine.cap_fps).toFixed(
fixedPoint,
),
);
pb = parseFloat((pb % video_plane.duration).toFixed(fixedPoint));
if (video_plane.currentTime !== pb) {
video_plane.playbackRate = 6;
video_plane.currentTime = pb;
}
}
//this.scrubber_frame_position += 1;
//2. allow stopping.
//3. smallest unit is a frame and it is set by the scene and is in fps, our videos will be 60fps but we can reprocess them using the pipeline.
// Since the animation engine is newer we can just call an evaluation on it instead of having the timeline process it
this.animation_engine.evaluate(this.current_time, this.timeline_limit);
// Iterate over the clips and play them as fit
for (const element of this.timeline_items) {
if (
element.offset <= this.scrubber_frame_position &&
this.scrubber_frame_position <= element.length &&
element.should_play
) {
// run async
// element.play()
// remove the element from the list
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (element.type === ClipType.TRANSFORM) {
if (object && this.transform_engine.clips[element.object_uuid]) {
this.transform_engine.clips[element.object_uuid].step(
object,
element.offset,
this.scrubber_frame_position,
this.scene,
);
element.length =
this.transform_engine.clips[element.object_uuid].length;
}
} else if (
element.type === ClipType.AUDIO &&
element.group !== ClipGroup.CHARACTER &&
this.is_playing &&
!isRendering
) {
// if (this.scrubber_frame_position + 1 >= element.length) {
// this.audio_engine.playClip(element.media_id);
// } else {
// await this.audio_engine.step(
// element.object_uuid + element.media_id,
// this.scrubber_frame_position, element.offset);
// }
this.audio_engine.playClip(element.media_id);
await this.audio_engine.step(
element.media_id,
this.scrubber_frame_position,
element.offset,
);
} else if (
element.type === ClipType.AUDIO &&
element.group === ClipGroup.CHARACTER &&
this.is_playing
) {
if (object) {
await this.lipSync_engine.clips[
element.object_uuid + element.media_id
].play(object);
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].step(this.scrubber_frame_position, element.offset);
}
} else if (element.type === ClipType.EXPRESSION) {
if (object) {
await this.emotion_engine.clips[
object.uuid + element.media_id
].step(this.scrubber_frame_position - element.offset, object);
}
}
}
}
if (
this.scrubber_frame_position >= this.timeline_limit &&
this.is_playing
) {
await this.stop();
return true;
}
return false;
}
private async stop(): Promise<void> {
await this.resetScene();
this.is_playing = false;
console.log(`Stop - Stopping Timeline`);
this.current_time = 0;
this.update();
this.pushEvent(fromEngineActions.UPDATE_TIME, {
currentTime: this.current_time,
});
}
}
@@ -1,474 +0,0 @@
import Editor from "./editor";
import { EditorStates } from "~/pages/PageEnigma/enums";
import { editorState, previewSrc } from "../signals/engine";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { ToastTypes, ClipType } from "~/enums";
import { Visibility } from "./api_manager.js";
import * as THREE from "three";
import { getSceneSignals, addToast, startPollingActiveJobs } from "~/signals";
import { v4 as uuidv4 } from "uuid";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import { MediaUploadApi, StudioGen2Api, VideoApi } from "~/Classes/ApiManager";
import { globalIPAMediaToken } from "../signals";
// TODO THIS CLASS MAKES NO SENSE
// Refactor so we generate all the frames first.
// then pass it through this pipeline as a data structure process it.
// through this class.
interface MediaTokens {
// NB(bt,2025-01-27): This media token was likely called "color" because we were originally
// going to send a shaded "color" render, a depth map render, and an edge detect render to
// the backend for inference.
color: string;
}
export class VideoGeneration {
editor: Editor;
mediaUploadAPI: MediaUploadApi;
videoAPI: VideoApi;
studioGen2Api: StudioGen2Api;
// For cached style Re-Generation
private last_scene_check_sum: string;
// Last Media token IDs
private last_media_tokens: MediaTokens;
// Last toggle position for Render
public last_position_of_preprocessing: boolean;
constructor(editor: Editor) {
this.editor = editor;
this.mediaUploadAPI = new MediaUploadApi();
this.videoAPI = new VideoApi();
this.studioGen2Api = new StudioGen2Api();
this.last_scene_check_sum = "";
this.last_media_tokens = { color: "" };
this.last_position_of_preprocessing = false;
}
sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async videoProcessingPipeline(title: string): Promise<MediaTokens> {
const mediaTokens: MediaTokens = {
color: "",
};
try {
// TODO send the audio a later time.
// const audioClips = this.editor.timeline.timeline_items.filter((clip) => {
// return clip.type == ClipType.AUDIO;
// });
if (!this.editor.engineFrameBuffers.payloadZip) {
await this.handleError(
`Video Processing Failed Please Try Again (zip process failed)`,
3000,
);
}
const zipBlob = this.editor.engineFrameBuffers.payloadZip;
if (!zipBlob) {
await this.handleError(`"Payload blob is null."`, 3000);
}
const video_upload_response = await this.mediaUploadAPI.UploadStudioShot({
maybe_title: title,
uuid_idempotency_token: uuidv4(),
blob: zipBlob,
fileName: `${title}.zip`,
maybe_visibility: Visibility.Public,
});
if (!video_upload_response.data) {
console.log("Missing media_tokens.color Token");
const error = {
message: "Missing Data From Video Upload Response",
code: 1,
};
throw error;
}
mediaTokens.color = video_upload_response.data;
console.log(`Studio recording: https://storyteller.ai/media/${mediaTokens.color}`);
} finally {
}
return mediaTokens;
}
// this is to generate previews
async generateFrame() {
if (!this.editor.generating_preview && this.editor.rawRenderer) {
this.editor.generating_preview = true;
this.editor.utils.removeTransformControls();
this.editor.activeScene.renderMode(true);
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = false;
});
}
previewSrc.value = "";
this.editor.rawRenderer.setSize(
this.editor.render_width,
this.editor.render_height,
);
this.editor.render_camera.aspect =
this.editor.render_width / this.editor.render_height;
this.editor.render_camera.updateProjectionMatrix();
this.editor.rawRenderer.render(
this.editor.activeScene.scene,
this.editor.render_camera,
);
const imgData = this.editor.rawRenderer.domElement.toDataURL();
const response = await fetch(imgData); // Fetch the data URL
const blob = await response.blob(); // Convert to Blob
if (!this.editor.camera_person_mode) {
this.editor.switchCameraView();
editorState.value = EditorStates.PREVIEW;
}
this.editor.generating_preview = false;
try {
const url = await this.editor.api_manager.uploadMediaFrameGeneration(
blob,
"render.png",
this.editor.art_style,
this.editor.positive_prompt,
this.editor.negative_prompt,
);
previewSrc.value = url;
return Promise.resolve(url);
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: "Unknown Error in Generate Frame";
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.POP_A_TOAST,
data: {
type: ToastTypes.ERROR,
message: errorMessage,
},
});
}
}
}
// to determine if we should capture frames again or not.
async shouldRenderScenesAgain(checkSumData: string): Promise<boolean> {
// Should skip to rendering the scene if already processed the frames inputs for running a new style.
if (this.last_scene_check_sum === "") {
// no token means input was not processed so lets go do the video re-render process
this.last_scene_check_sum = checkSumData;
return false;
} else {
if (this.last_scene_check_sum === checkSumData) {
return true;
} else {
this.last_scene_check_sum = checkSumData;
return false;
}
}
}
async stopPlaybackAndUploadVideo() {
this.editor.rendering = false; // not sure about this variable ... this has so many state issues.
// precondition checks, if we have no frames then we shouldn't try to do generations or snapshots
// This means no frames so error out
this.editor.generating_preview = true;
await this.editor.updateLoad({
progress: 50,
message:
"Please stay on this screen and do not switch tabs! while your video is being processed.",
});
let media_tokens: MediaTokens = {
color: "",
};
// properties used by the external scope to the for loop.
const title = getSceneSignals().title || "Untitled";
const style_name = this.editor.art_style.toString();
const media_token = this.editor.current_scene_media_token || undefined;
// TAKE A snap shot of the scene then use this token across all videos
// convert the ip adapter image and upload as a media token
const image_uuid = uuidv4();
let ipa_image_token = undefined;
if (this.editor.globalIpAdapterImage != undefined) {
const response = await this.mediaUploadAPI.UploadImage({
fileName: `${image_uuid}.ipa`,
blob: this.editor.globalIpAdapterImage,
uuid: image_uuid,
});
if (!response.success) {
// ip adapter upload failed
await this.handleError(
"Reference Image Upload Failed Try Generating Movie Again.",
3000,
);
return;
}
if (!response.data) {
console.log("IPA Image Token Missing");
}
ipa_image_token = response.data;
}
// update the signal with this information
if (ipa_image_token) {
globalIPAMediaToken.value = ipa_image_token;
}
// TODO Remove so many of these around wtf. SceneGenereationMetaData should only be one place
const metaData: SceneGenerationMetaData = {
artisticStyle: this.editor.art_style,
positivePrompt: this.editor.positive_prompt,
negativePrompt: this.editor.negative_prompt,
cameraAspectRatio: this.editor.render_camera_aspect_ratio,
upscale: this.editor.generation_options.upscale,
faceDetail: this.editor.generation_options.faceDetail,
styleStrength: this.editor.generation_options.styleStrength,
lipSync: this.editor.generation_options.lipSync,
cinematic: this.editor.generation_options.cinematic,
globalIPAMediaToken: ipa_image_token,
enginePreProcessing: this.editor.engine_preprocessing, // the only thing that will invalidate the cache
};
// for the one case where the engine preprecessing is turned we need to cache this.
this.last_position_of_preprocessing = this.editor.engine_preprocessing;
// This is to save the snapshot of the scene for remixing
const uuid_snapshot = uuidv4();
// Save the scene
const saveData = await this.editor.save_manager.saveData({
sceneTitle: title,
sceneToken: media_token,
sceneGenerationMetadata: metaData,
});
const file = new File([saveData], `${title}.glb`, {
type: "application/json",
});
const response =
await this.editor.media_upload.UploadSceneSnapshotMediaFileForm({
maybe_title: title,
maybe_scene_source_media_file_token: media_token, // can be undefined or null
uuid: uuid_snapshot,
blob: file,
});
let immutable_media_token = undefined;
if (response.success) {
if (response.data) {
immutable_media_token = response.data;
}
} else {
await this.handleError(
"Scene Snapshot Failed Try Generating Movie Again.",
3000,
);
return;
}
console.log(`Immutable Snapshot Token: ${immutable_media_token}`);
await this.editor.updateLoad({
progress: 50,
message:
"Please stay on this screen and do not switch tabs! while your video is being processed.",
});
// write the end point to upload the video
// upload the color frames with end point.
try {
media_tokens = await this.videoProcessingPipeline(title);
} catch (error) {
await this.handleError(`Video Processing Failed Please Try Again`, 3000);
throw error;
}
this.editor.onWindowResize();
this.editor.setColorMap();
await this.editor.updateLoad({
progress: 100,
message: "Done Check Your Movies Tab On Profile.",
label: "Success",
});
// this is so that its a check point just encase enqueue fails, if it does we can still restylize
this.last_media_tokens = media_tokens;
// Legacy studio:
//await this.handleEnqueue(media_tokens, ipa_image_token ?? "");
// NB(bt,2025-01-27): We're using a hack to set the first frame (in CharacterFrameButton)
const firstFrameMediaToken = (window as any).firstFrameMediaToken;
const videoMediaToken = media_tokens.color;
console.log(`Enqueue Studio Gen2 Render; Video: ${videoMediaToken} Image: ${firstFrameMediaToken}`);
await this.handleEnqueueStudioGen2(videoMediaToken, firstFrameMediaToken);
await this.EndLoadingState();
}
async handleCachedEnqueue() {
const enqueue_studio_response = await this.videoAPI.EnqueueStudio({
enqueueVideo: {
disable_lcm: false,
enable_lipsync: this.editor.generation_options.lipSync,
input_file: this.last_media_tokens.color,
negative_prompt: this.editor.negative_prompt,
prompt: this.editor.positive_prompt,
remove_watermark: false,
style: this.editor.art_style.toString(),
frame_skip: 2,
travel_prompt: "",
trim_end_millis: 7000,
trim_start_millis: 0,
use_cinematic: this.editor.generation_options.cinematic,
use_face_detailer: this.editor.generation_options.faceDetail,
use_strength: this.editor.generation_options.styleStrength,
use_upscaler: this.editor.generation_options.upscale,
uuid_idempotency_token: uuidv4(),
global_ipa_media_token: globalIPAMediaToken.value ?? "",
input_depth_file: "",
input_normal_file: "",
input_outline_file: "",
creator_set_visibility: Visibility.Public,
},
});
if (enqueue_studio_response.success) {
console.log("Start Polling Active Jobs");
startPollingActiveJobs();
addToast(
ToastTypes.SUCCESS,
"Done Check Your Movies Tab On Profile.",
3000,
);
} else {
addToast(ToastTypes.ERROR, "Failed To Process Movie Try Again", 3000);
}
}
async handleEnqueue(upload_tokens: MediaTokens, ipa_image_token: string) {
const enqueue_studio_response = await this.videoAPI.EnqueueStudio({
enqueueVideo: {
disable_lcm: false,
enable_lipsync: this.editor.generation_options.lipSync,
input_file: upload_tokens.color,
negative_prompt: this.editor.negative_prompt,
prompt: this.editor.positive_prompt,
remove_watermark: false,
style: this.editor.art_style.toString(),
frame_skip: 2,
travel_prompt: "",
trim_end_millis: 7000,
trim_start_millis: 0,
use_cinematic: this.editor.generation_options.cinematic,
use_face_detailer: this.editor.generation_options.faceDetail,
use_strength: this.editor.generation_options.styleStrength,
use_upscaler: this.editor.generation_options.upscale,
uuid_idempotency_token: uuidv4(),
global_ipa_media_token: ipa_image_token,
input_depth_file: "",
input_normal_file: "",
input_outline_file: "",
creator_set_visibility: Visibility.Public,
},
});
if (enqueue_studio_response.success) {
startPollingActiveJobs();
} else {
await this.handleError("Failed To Process Movie Try Again", 3000);
return;
}
this.handleSuccess("Done Check Your Movies Tab On Profile.", 3000);
}
async handleEnqueueStudioGen2(videoMediaToken: string, firstFrameImageToken: string) {
const response = await this.studioGen2Api.EnqueueStudioGen2({
enqueueVideo: {
uuid_idempotency_token: uuidv4(),
image_file: firstFrameImageToken,
video_file: videoMediaToken,
creator_set_visibility: Visibility.Public,
},
});
if (response.success) {
startPollingActiveJobs();
} else {
await this.handleError("Failed To Process Animation Try Again", 3000);
return;
}
this.handleSuccess("Done Check Your Movies Tab On Profile.", 3000);
}
async handleSuccess(message: string, timeout: number) {
addToast(ToastTypes.SUCCESS, message, timeout);
}
async handleError(message: string, timeout: number) {
addToast(ToastTypes.ERROR, message, timeout);
await this.EndLoadingState();
}
async EndLoadingState() {
this.editor.generating_preview = false;
this.editor.endLoading();
this.editor.onWindowResize();
this.editor.recorder = undefined;
if (this.editor.rawRenderer) {
this.editor.rawRenderer.setSize(
this.editor.startRenderWidth,
this.editor.startRenderHeight,
);
}
this.editor.camViewCanvasMayReset();
this.editor.rawRenderer = new THREE.WebGLRenderer({
antialias: true,
canvas: this.editor.canvasRenderCamReference || undefined,
preserveDrawingBuffer: true,
});
this.editor._configurePostProcessingRaw();
this.editor.activeScene.renderMode(false);
this.editor.switchEdit();
}
}
@@ -1,47 +0,0 @@
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { persistLogin } from "~/signals";
import { pageHeight, pageWidth } from "~/signals";
import { useEffect } from "react";
export const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
useSignals();
useSignalEffect(() => {
persistLogin();
});
/// Initizations that depends on ENV vars ///
function PostHogInit() {
const data = EnvironmentVariables.values;
const apiKey = data.REACT_APP_PUBLIC_POSTHOG_KEY as string;
//posthog.init(apiKey, {
// //HACK: This is the default host from Netlify, but need to figure out why it isn't working on prod.
// // api_host: data.DEPLOY_PRIME_URL + "/ingest" as string,
// api_host: "https://studio.storyteller.ai/ingest" as string,
// ui_host: data.REACT_APP_PUBLIC_POSTHOG_UI as string,
//});
}
useEffect(() => {
EnvironmentVariables.initialize(env);
PostHogInit();
}, [env]);
/// Initizations that run only once on 1ST mount ///
function setPage() {
// TODO address this issue with zooming
pageHeight.value = window.innerHeight;
pageWidth.value = window.innerWidth;
}
useEffect(() => {
setPage();
window.addEventListener("resize", setPage);
return () => {
window.removeEventListener("resize", setPage);
};
}, []);
return null;
};
@@ -1,58 +0,0 @@
import { SoundRegistry, SoundEffect } from "@storyteller/soundboard";
// TODO: This is maintained in two places. Here and MiscSettingsPane.
export const InstallSounds = () => {
const r = SoundRegistry.getInstance();
if (r.hasSound("decline_normal")) {
return;
}
// Good for simple menu choices
r.setSoundOnce("click", new SoundEffect("resources/sound/smrpg_click.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_menu_beep_1", new SoundEffect("resources/sound/metroidprime_UI_15.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_beep_2", new SoundEffect("resources/sound/metroidprime_UI_14.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_select", new SoundEffect("resources/sound/metroidprime_UI_18.wav", { defaultVolume: 0.3 }));
// Good for simple immediate enqueue success
r.setSoundOnce("done", new SoundEffect("resources/sound/oot_dialogue_done.wav", { defaultVolume: 0.4 })); // DEFAULT
// Good for simple immediate failure
r.setSoundOnce("error_chirp", new SoundEffect("resources/sound/goldensun_135.wav", { defaultVolume: 0.4 }));
r.setSoundOnce("spike_throw", new SoundEffect("resources/sound/smrpg_enemy_spikethrow.wav", { defaultVolume: 0.1 })); // DEFAULT
r.setSoundOnce("giant_shell_kick", new SoundEffect("resources/sound/smrpg_mario_giantshellkick.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("wrong", new SoundEffect("resources/sound/smrpg_wrong.wav", { defaultVolume: 0.4 }));
// Good for simple async success
r.setSoundOnce("special_flower", new SoundEffect("resources/sound/smrpg_specialflower.wav", { defaultVolume: 0.2 })); // DEFAULT
r.setSoundOnce("extra_power", new SoundEffect("resources/sound/smrpg_character_extrapower.wav", { defaultVolume: 0.2 })); // DEFAULT
// Good for async errors
r.setSoundOnce("crumble", new SoundEffect("resources/sound/smrpg_drybones_crumble.wav", { defaultVolume: 0.1 })); // DEFAULT
r.setSoundOnce("ghost", new SoundEffect("resources/sound/smrpg_ghost.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("special_alert", new SoundEffect("resources/sound/goldensun_214.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_alert", new SoundEffect("resources/sound/metroidprime_UI_52.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_shrill_alert", new SoundEffect("resources/sound/metroidprime_UI_51.wav", { defaultVolume: 0.2 }));
// Good for menus
r.setSoundOnce("next", new SoundEffect("resources/sound/oot_dialogue_next.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("select", new SoundEffect("resources/sound/goldensun_111.wav", { defaultVolume: 0.4 }));
r.setSoundOnce("scifi_menu_open", new SoundEffect("resources/sound/metroidprime_UI_12.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_close", new SoundEffect("resources/sound/metroidprime_UI_13.wav", { defaultVolume: 0.3 }));
// Good for special reward / celebration
r.setSoundOnce("correct", new SoundEffect("resources/sound/smrpg_correct.wav", { defaultVolume: 0.1 }));
r.setSoundOnce("flower", new SoundEffect("resources/sound/smrpg_flower.wav", { defaultVolume: 0.1 }));
// File delete, trash, etc.
r.setSoundOnce("trash", new SoundEffect("resources/sound/oot_scrub_crumble.wav", { defaultVolume: 0.4 }));
// Misc / ungrouped
r.setSoundOnce("accept_chirp", new SoundEffect("resources/sound/goldensun_101.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_1", new SoundEffect("resources/sound/goldensun_173.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_2", new SoundEffect("resources/sound/goldensun_174.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_3", new SoundEffect("resources/sound/goldensun_175.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("decline_chirp", new SoundEffect("resources/sound/goldensun_102.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("decline_normal", new SoundEffect("resources/sound/goldensun_113.wav", { defaultVolume: 0.2 }));
(window as any).sounds = r;
}
@@ -1,662 +0,0 @@
import React, { useContext, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { Controls3D } from "./comps/Controls3D";
import { ControlsTopButtons } from "./comps/ControlsTopButtons";
import { ControlPanelSceneObject } from "./comps/ControlPanelSceneObject";
import { PreviewEngineCamera } from "./comps/PreviewEngineCamera";
import { PreviewFrameImage } from "./comps/PreviewFrameImage";
import { authentication, pageHeight, pageWidth } from "~/signals";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import ImageToVideo from "../PageVideo/ImageToVideo";
import TextToImage from "../PageImage/TextToImage";
import { AppsIndexPage } from "../PageApps/AppsIndexPage";
import { VideoFrameExtractor } from "../PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "../PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "../PageImageWatermarkRemover";
import { ImageTo3DObject } from "../PageImageTo3DObject";
import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Storyboard } from "../PageStoryboard";
import { PageBackgroundChange } from "../PageBackgroundChange";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
timelineHeight,
sidePanelWidth,
dndTimelineHeight,
editorLoader,
cameraAspectRatio,
outlinerIsShowing,
disableHotkeyInput,
enableHotkeyInput,
gridVisibility,
setGridVisibility,
DomLevels,
} from "~/pages/PageEnigma/signals";
import { EditorCanvas } from "./comps/EngineCanvases";
import { SceneContainer } from "./comps/SceneContainer";
import { Outliner } from "./comps/Outliner";
import { CameraAspectRatio } from "./enums";
import { PromptBox3D } from "@storyteller/ui-promptbox";
import { PopoverItem } from "@storyteller/ui-popover";
import { LoadingDots } from "@storyteller/ui-loading";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
import {
addCamera,
cameras,
deleteCamera,
focalLengthDragging,
selectedCameraId,
updateCamera,
} from "./signals/camera";
import { isPromptBoxFocused } from "./signals/promptBox";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { EngineContext } from "./contexts/EngineContext";
import Queue, { QueueNames } from "./Queue";
import { toEngineActions } from "./Queue/toEngineActions";
import { UnionedDataTypes } from "./Queue/Queue";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
import { uploadPlaneFromMediaToken } from "~/components/reusable/UploadModalMedia/uploadPlane";
import { addObject } from "./signals/objectGroup/addObject";
import { addCharacter } from "./signals/characterGroups/addCharacter";
import { AssetType, AUTH_STATUS } from "~/enums";
import { v4 as uuidv4 } from "uuid";
import { MediaItem } from "~/pages/PageEnigma/models";
import { UploaderState } from "~/models";
import * as THREE from "three";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
STAGE_3D_PAGE_MODEL_LIST,
ModelPage,
useSelectedImageModel,
useSelectedProviderForModel,
ClassyModelSelector,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import { ImageModel } from "@storyteller/model-list";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
export const PageEditor = () => {
useSignals();
const { triggerRecheck } = useLoginModalStore();
const { status } = authentication;
useEffect(() => {
if (status.value === AUTH_STATUS.LOGGED_OUT) {
triggerRecheck();
}
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
useEffect(() => {
timelineHeight.value = 0; //timelineHeight.value = 208;
sidePanelWidth.value = 340;
window.onbeforeunload = () => {
return "You may have unsaved changes.";
};
}, []);
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
const selectedProvider: GenerationProvider | undefined =
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
const imageCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[PAGE_ID],
);
const height =
dndTimelineHeight.value > -1
? pageHeight.value - dndTimelineHeight.value - 56
: pageHeight.value - timelineHeight.value - 56;
const getScale = () => {
const height = pageHeight.value - timelineHeight.value - 56;
const scaleHeight = height < 610 ? height / 610 : 1;
if (
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_9_16 &&
outlinerIsShowing.value &&
height < 900
) {
if (pageWidth.value > 2000) {
return scaleHeight;
}
return scaleHeight * 0.78;
}
if (
cameraAspectRatio.value === CameraAspectRatio.SQUARE_1_1 &&
pageWidth.value < 2000
) {
return scaleHeight * 0.85;
}
return scaleHeight;
};
// These are callbacks required by promptbox
const editorEngine = useContext(EngineContext);
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.value.find(
(cam) => cam.label === selectedItem.label,
);
if (selectedCamera && editorEngine) {
selectedCameraId.value = selectedCamera.id;
// Show focal length display temporarily
// TODO: Rename dragging to visible - BFlat
focalLengthDragging.value = {
isDragging: true,
focalLength: selectedCamera.focalLength,
};
setTimeout(() => {
focalLengthDragging.value = {
isDragging: false,
focalLength: selectedCamera.focalLength,
};
}, 1500);
// Update the main camera to match the selected camera's properties
if (editorEngine.camera) {
// First update position and lookAt
editorEngine.camera.position.set(
selectedCamera.position.x,
selectedCamera.position.y,
selectedCamera.position.z,
);
editorEngine.camera.lookAt(
selectedCamera.lookAt.x,
selectedCamera.lookAt.y,
selectedCamera.lookAt.z,
);
// Update FOV
editorEngine.camera.fov = editorEngine.focalLengthToFov(
selectedCamera.focalLength,
);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
}
// Force update camera properties in the state
updateCamera(selectedCamera.id, {
focalLength: selectedCamera.focalLength,
position: selectedCamera.position,
rotation: selectedCamera.rotation,
lookAt: selectedCamera.lookAt,
});
}
};
const handleAddCamera = () => {
// Check if we've reached the maximum number of cameras
if (cameras.value.length >= 6) {
console.warn("Maximum number of cameras (6) reached");
return;
}
const newIndex = cameras.value.length + 1;
const newId = `cam${newIndex}`;
// This is for generating random orbital position for the new camera using spherical coordinates
const radius = Math.random() * 5 + 7; // Distance from center: 7 to 12 units
const theta = Math.random() * Math.PI * 2; // Azimuthal angle: 0 to 2π
const phi = Math.PI / 3 + (Math.random() * Math.PI) / 6; // Polar angle: π/3 to π/2 (60° to 90° from horizontal)
// Convert spherical coordinates to Cartesian coordinates
const randomX = radius * Math.sin(phi) * Math.cos(theta);
const randomY = Math.abs(radius * Math.cos(phi)) + 2; // Ensure Y is positive and at least 2 units up
const randomZ = radius * Math.sin(phi) * Math.sin(theta);
addCamera({
id: newId,
label: `Camera ${newIndex}`,
focalLength: 24,
position: {
x: randomX,
y: randomY,
z: randomZ,
},
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
});
// Switch to the newly created camera
selectedCameraId.value = newId;
// Update the engine camera to match the new camera's properties
if (editorEngine && editorEngine.camera) {
editorEngine.camera.position.set(randomX, randomY, randomZ);
editorEngine.camera.lookAt(0, 0, 0);
editorEngine.camera.fov = editorEngine.focalLengthToFov(24);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
}
};
const handleCameraNameChange = (id: string, newName: string) => {
updateCamera(id, { label: newName });
};
const handleCameraFocalLengthChange = (id: string, value: number) => {
const camera = cameras.value.find((cam) => cam.id === id);
if (camera) {
updateCamera(id, { focalLength: value });
}
};
const onAspectRatioSelect = (newRatio: UnionedDataTypes) => {
// Publish the change to the engine
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
data: newRatio,
});
};
// MOVE THIS don't throw this in here
// Image drop from gallery/library modal logic
useEffect(() => {
let handler: unknown;
// 3D Drag and Drop Logic
if (tabStore.activeTabId === "3D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("3D Drop debug (event):", {
editorEngine,
camera: editorEngine?.camera,
renderer: editorEngine?.renderer,
position,
});
// Calculate world position from cursor immediately on drop
let worldPosition = undefined;
if (editorEngine?.camera && editorEngine?.renderer && position) {
const rect =
editorEngine.renderer.domElement.getBoundingClientRect();
// Convert client coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
const ndcX = (canvasX / rect.width) * 2 - 1;
const ndcY = -(canvasY / rect.height) * 2 + 1;
const vector = new THREE.Vector3(ndcX, ndcY, 0.5);
vector.unproject(editorEngine.camera);
worldPosition = vector;
}
(async () => {
try {
if (item.mediaClass === "dimensional") {
// Handle 3D models
const isCharacter = item.assetType === "character";
const mediaItem: MediaItem = {
version: 1,
type: isCharacter ? AssetType.CHARACTER : AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || (isCharacter ? "Character" : "3D Object"),
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
if (isCharacter) {
addCharacter(mediaItem);
} else {
addObject(mediaItem);
}
} else {
// Handle images (create image planes)
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status)
console.log("Upload status:", state.status);
},
});
}
} catch (err) {
console.error("Failed to add object to scene:", err);
}
})();
},
);
// 2D Drag and Drop Logic
} else if (tabStore.activeTabId === "2D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("2D Drop debug (event):", {
item,
position,
});
// Find the main Konva canvas element - get the first canvas (left panel)
const canvasElements = document.querySelectorAll("canvas");
const canvasElement = canvasElements[0]; // Get the main drawing canvas (left panel)
if (!canvasElement) {
console.error("Could not find canvas element for 2D drop");
return;
}
const rect = canvasElement.getBoundingClientRect();
// Convert screen coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
// Ensure the drop position is within canvas bounds
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
console.log("Drop position outside canvas bounds");
return;
}
console.log("Canvas drop position:", { canvasX, canvasY });
(async () => {
try {
// event that PageDraw listens for
const dropEvent = new CustomEvent("gallery-2d-drop", {
detail: {
item,
canvasPosition: { x: canvasX, y: canvasY },
},
});
window.dispatchEvent(dropEvent);
} catch (err) {
console.error("Failed to add image to 2D canvas:", err);
}
})();
},
);
}
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabStore.activeTabId, editorEngine]);
return (
<div className="w-screen">
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
{tabStore.activeTabId == "3D" && (
<div>
<OnboardingHelper />
<div
className="relative flex w-screen"
style={{ height: "calc(100vh - 68px)" }}
>
{/* Engine section/side panel */}
<div
id="engine-n-panels-wrapper"
className="flex"
style={{
height,
}}
>
<div className="relative w-full overflow-hidden bg-transparent">
<SceneContainer>
<EditorCanvas />
<PreviewFrameImage />
</SceneContainer>
{/* Focal Length Display */}
<FocalLengthDisplay />
{/* Pose Mode Selector */}
<PoseModeSelector />
{/* Top controls */}
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D />
</div>
</div>
{/* Bottom controls */}
<div
className="absolute bottom-0 left-0"
style={{
width: pageWidth.value,
}}
onClick={handleOverlayClick}
>
<div
className="absolute bottom-20 mb-4 ml-4 flex origin-bottom-left flex-col gap-2"
style={{ transform: `scale(${getScale()})` }}
>
<Outliner />
<PreviewEngineCamera />
</div>
<ControlPanelSceneObject />
</div>
<PromptBox3D
cameras={cameras}
cameraAspectRatio={cameraAspectRatio}
disableHotkeyInput={disableHotkeyInput}
enableHotkeyInput={enableHotkeyInput}
gridVisibility={gridVisibility}
setGridVisibility={setGridVisibility}
selectedCameraId={selectedCameraId}
deleteCamera={deleteCamera}
focalLengthDragging={focalLengthDragging}
isPromptBoxFocused={isPromptBoxFocused}
uploadImage={uploadImage}
handleCameraSelect={handleCameraSelect}
handleAddCamera={handleAddCamera}
handleCameraNameChange={handleCameraNameChange}
handleCameraFocalLengthChange={handleCameraFocalLengthChange}
onAspectRatioSelect={onAspectRatioSelect}
selectedImageModel={selectedImageModel}
selectedProvider={selectedProvider}
credits={imageCredits}
setEnginePrompt={(prompt) => {
console.log("setEnginePrompt", prompt);
if (!editorEngine) {
console.log("editorEngine is not available");
return;
}
editorEngine!.positive_prompt = prompt;
}}
snapshotCurrentFrame={editorEngine?.snapShotOfCurrentFrame.bind(
editorEngine,
)}
/>
<LoadingDots
className="absolute left-0 top-0 z-50"
isShowing={editorLoader.value.isShowing}
type="bricks"
message={editorLoader.value.message}
/>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</div>
</div>
</div>
</div>
</div>
)}
{tabStore.activeTabId == "APPS" && (
<div>
<AppsIndexPage />
</div>
)}
{tabStore.activeTabId == "2D" && (
<div>
<PageDraw />
</div>
)}
{tabStore.activeTabId == "IMAGE" && (
<div>
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
)}
{tabStore.activeTabId == "VIDEO" && (
<div>
<ImageToVideo />
</div>
)}
{tabStore.activeTabId == "VIDEO_FRAME_EXTRACTOR" && (
<div>
<VideoFrameExtractor />
</div>
)}
{tabStore.activeTabId == "VIDEO_WATERMARK_REMOVAL" && (
<div>
<VideoWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_WATERMARK_REMOVAL" && (
<div>
<ImageWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_OBJECT" && (
<div>
<ImageTo3DObject />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_WORLD" && (
<div>
<ImageTo3DWorld />
</div>
)}
{tabStore.activeTabId == "REMOVE_BACKGROUND" && (
<div>
<RemoveBackground />
</div>
)}
{tabStore.activeTabId == "ANGLES" && (
<div>
<Angles />
</div>
)}
{tabStore.activeTabId == "STORYBOARD" && storyboardPageEnabled && (
<div>
<Storyboard />
</div>
)}
{tabStore.activeTabId == "BACKGROUND_CHANGE" && (
<div>
<PageBackgroundChange />
</div>
)}
</div>
);
};
@@ -1,179 +0,0 @@
import { DragComponent } from "~/pages/PageEnigma/comps/DragComponent/DragComponent";
import { EngineProvider } from "./contexts/EngineContext";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { useQueueHandler } from "./hooks/useQueueHandler";
import { ErrorDialog } from "~/components";
import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals";
import { toast, Toaster } from "@storyteller/ui-toaster";
import { EditorLoadingBar } from "./comps/EditorLoadingBar";
import { useSignals } from "@preact/signals-react/runtime";
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { UsersApi } from "~/Classes/ApiManager";
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
import {
precisionSelectedValue,
precisionSelectorMenuCoords,
precisionSelectorValues,
showPrecisionSelector,
} from "./signals/precisionSelectorMenu";
import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import {
PricingModal,
CreditsModal,
useCreditsModalStore,
} from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import { useFlashFileDownloadErrorEvent, useFlashUserInputErrorEvent, useMediaFileDeletedEvent } from "@storyteller/tauri-events";
import { useGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueFailureEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueSuccessEvent } from "@storyteller/tauri-events";
import { useGenerationFailedEvent } from "@storyteller/tauri-events";
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useTextToImageStore } from "~/pages/PageImage/TextToImageStore";
import { GetAppPreferences } from "@storyteller/tauri-api";
import { SoundRegistry } from "@storyteller/soundboard";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
useActiveJobs();
useBackgroundLoadingMedia();
useQueueHandler();
// Credits modal state (must be before any early returns)
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
const [validGpu, setValidGpu] = useState("unknown");
useEffect(() => {
const usersApi = new UsersApi();
const sessionResponse = usersApi.GetSession();
sessionResponse.then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
});
});
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) {
isValid = true;
}
if (fps > 15) {
isValid = true;
}
switch (gpuTier.gpu) {
case "apple gpu (Apple GPU)":
isValid = true;
break;
default:
break;
}
setValidGpu(isValid ? "valid" : "error");
});
});
useGenerationEnqueueSuccessEvent();
useGenerationEnqueueFailureEvent();
useGenerationCompleteEvent();
useGenerationFailedEvent();
const completeBatch = useTextToImageStore((s) => s.completeBatch);
useTextToImageGenerationCompleteEvent(async (event) => {
completeBatch(
event.generated_images || [],
event.maybe_frontend_subscriber_id,
);
});
useFlashUserInputErrorEvent(async (event) => {
console.log("Flash user input error event received:", event);
toast.error(event.message);
});
useFlashFileDownloadErrorEvent(async (event) => {
console.log("Flash file download error event received:", event);
toast.error(event.message || "File download failed");
});
useMediaFileDeletedEvent(async (event) => {
console.log("Media file deleted event received:", event);
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.delete_file_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.error("File deleted.");
});
useEffect(() => {
console.log("installing event listeners");
InstallSounds();
});
const currentReminderModalProps = actionReminderProps.value;
return (
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
<GalleryDragComponent />
<PrecisionSelector
showSignal={showPrecisionSelector}
coordSignal={precisionSelectorMenuCoords}
valuesSignal={precisionSelectorValues}
selectedValueSignal={precisionSelectedValue}
/>
<GenerateModals />
<ErrorDialog />
<EditorLoadingBar />
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
{currentReminderModalProps && (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={currentReminderModalProps.onClose}
reminderType={currentReminderModalProps.reminderType}
onPrimaryAction={currentReminderModalProps.onPrimaryAction}
title={currentReminderModalProps.title}
message={currentReminderModalProps.message}
primaryActionText={currentReminderModalProps.primaryActionText}
secondaryActionText={currentReminderModalProps.secondaryActionText}
onSecondaryAction={currentReminderModalProps.onSecondaryAction}
isLoading={currentReminderModalProps.isLoading}
openAiLogo={currentReminderModalProps.openAiLogo}
primaryActionIcon={currentReminderModalProps.primaryActionIcon}
primaryActionBtnClassName={
currentReminderModalProps.primaryActionBtnClassName
}
/>
)}
<PricingModal />
<CreditsModal isOpen={isCreditsOpen} onClose={closeCreditsModal} />
</EngineProvider>
);
};
@@ -1,127 +0,0 @@
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { CameraAspectRatio } from "../enums";
import {
QueueClip,
QueueKeyframe,
UpdateTime,
MediaItem,
} from "~/pages/PageEnigma/models";
import { toTimelineActions } from "./toTimelineActions";
import { ClipUI } from "../clips/clip_ui";
import { IGenerationOptions } from "../models/generationOptions";
import { ToastDataType } from "~/components";
import { QueueNames } from "./QueueNames";
export type UnionedActionTypes =
| fromEngineActions
| toEngineActions
| toTimelineActions;
export type UnionedDataTypes =
| QueueClip
| UpdateTime
| QueueKeyframe
| ClipUI[]
| MediaItem
| ToastDataType
| IGenerationOptions
| CameraAspectRatio
| null;
export type QueueSubscribeType = {
action: UnionedActionTypes;
data: UnionedDataTypes;
};
export type SubscribeHandler = (entry: {
action: UnionedActionTypes;
data: UnionedDataTypes;
}) => void;
class Queue {
private _queues: Record<
QueueNames,
Array<{
action: UnionedActionTypes;
data: UnionedDataTypes;
}>
> = {
[QueueNames.FROM_ENGINE]: [],
[QueueNames.TO_ENGINE]: [],
[QueueNames.TO_TIMELINE]: []
};
private _subscribers: Record<
QueueNames,
Map<
string,
SubscribeHandler
>
> = {
[QueueNames.FROM_ENGINE]: new Map<string, SubscribeHandler>(),
[QueueNames.TO_ENGINE]: new Map<string, SubscribeHandler>(),
[QueueNames.TO_TIMELINE]: new Map<string, SubscribeHandler>(),
};
// Enqueues actions to a specified queue
public publish({
queueName,
action,
data,
}: {
queueName: QueueNames;
action: UnionedActionTypes;
data: UnionedDataTypes;
}) {
// Queues for all QueueNames should be initialized above but this is a safeguard for future enum additions
// Make sure the queue exists
if (!this._queues[queueName]) {
this._queues[queueName] = [];
}
// Queue the task in the right queue
this._queues[queueName].push({ action, data });
// TODO: Ideally the publisher shouldn't deal at all with the subscribers
// It should only publish the events to the right queue
// It also doesn't make sense to fire an event on publish because the operations are then dispersed
// leading to potential race conditions
// FIXME: Add some sort of queue loop using internal event and add queue locks using promises to serialize queue publishes
if (this._subscribers[queueName]) {
this._subscribers[queueName].forEach((subcribeHandler) =>
subcribeHandler(this._queues[queueName][0]),
);
this._queues[queueName].shift();
}
}
public subscribe(
queueName: QueueNames,
id: string,
onMessage: (entry: QueueSubscribeType) => void,
) {
// Queues for all QueueNames should be initialized above but this is a safeguard for future enum additions
// Make sure the queue exists
if (!this._subscribers[queueName]) {
this._subscribers[queueName] = new Map<string, SubscribeHandler>();
}
// Set the handler mapped to the ID
this._subscribers[queueName].set(id, onMessage);
while (this._queues[queueName]?.length) {
this._subscribers[queueName].forEach((subscribeHandler) =>
subscribeHandler(this._queues[queueName][0]),
);
this._queues[queueName].shift();
}
}
}
const queue = new Queue();
export default queue;
@@ -1,5 +0,0 @@
export enum QueueNames {
FROM_ENGINE = "FROM_ENGINE",
TO_ENGINE = "TO_ENGINE",
TO_TIMELINE = "TO_TIMELINE",
}
@@ -1,14 +0,0 @@
export enum fromEngineActions {
ADD_OBJECT = "add_object",
CAMERA_ASPECT_RATIO_CHANGED = "camera_aspect_ratio_changed",
// data = CameraAspectRatio
DELETE_OBJECT = "delete_object",
DESELECT_OBJECT = "deselect_object",
RESET_TIMELINE = "reset_timeline",
SELECT_OBJECT = "select_object",
UPDATE_CHARACTER_ID = "update_character_id",
UPDATE_TIME = "update_time",
UPDATE_TIME_LINE = "update_timeline",
POP_A_TOAST = "pop_a_toast",
// data={currentTime: number}
}
@@ -1,3 +0,0 @@
import Queue from "./Queue";
export default Queue;
export * from "./QueueNames";
@@ -1,47 +0,0 @@
export enum toEngineActions {
ADD_CLIP = "add_clip",
// data=QueueClip
ADD_KEYFRAME = "add_keyframe",
ADD_CHARACTER = "add_character",
ADD_OBJECT = "add_object",
ADD_SHAPE = "add_shape",
// data= QueueKeyframe
DELETE_CLIP = "delete_clip",
// data=QueueClip
DELETE_KEYFRAME = "delete_keyframe",
// data=QueueKeyframe
ENTER_EDIT_STATE = "enter_edit",
// data=null
ENTER_PREVIEW_STATE = "enter_preview",
// data=null
GENERATE_VIDEO = "generate_video",
// data=null
MUTE = "mute",
// data={version, type, group, object_uuid} if lipsync
// data={version, type, group} if global_audio
REFRESH_PREVIEW = "refresh_preview",
// data=QueueClip
TOGGLE_CAMERA_STATE = "toggle_camera",
// data=null
TOGGLE_REPEATING = "toggle_repeating",
// data=null
CHANGE_CAMERA_ASPECT_RATIO = "change_camera_aspect_ratio",
// data=CamperaAspectRatio
CAMERA_CHANGED = "camera_changed",
UNMUTE = "unmute",
// data={version, type, group, object_uuid} if lipsync
// data={version, type, group} if global_audio
UPDATE_CLIP = "update_clip",
// data=QueueClip
UPDATE_KEYFRAME = "update_keyframe",
// data=QueueKeyframe
UPDATE_TIME = "update_time",
// data={currentTime: number}
}
@@ -1,3 +0,0 @@
export enum toInferenceActions {
ADD_TTS_JOB = "add_tts_job"
}
@@ -1,4 +0,0 @@
export enum toTimelineActions {
ADD_KEYFRAME = "add_keyframe",
GENERATE_PROGRESS = "generate_progress",
}
@@ -1,7 +0,0 @@
export default interface IAnimationClip {
speed: number;
duration: number;
startTime: number;
endTime: number;
evaluate: (time: number) => void;
}
@@ -1,348 +0,0 @@
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import {
FBXLoader,
MMDAnimationHelper,
MMDAnimationHelperMixer,
} from "three/examples/jsm/Addons.js";
import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
import { MoveAIResult, Retarget } from "../Editor/retargeting";
import IAnimationClip from "./ClipInterfaces/IAnimationClip";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { StorytellerApiHostStore } from "@storyteller/api";
export class AnimationClip implements IAnimationClip {
version: number;
media_id: string; // comes from the server
object_uuid: string;
type: "animation";
location: "glb" | "remote";
speed: number;
duration: number;
startTime: number;
length: number;
clip_name: string;
mixer: THREE.AnimationMixer | undefined;
animation_clip: THREE.AnimationClip | undefined;
clip_action: THREE.AnimationAction | undefined;
special_properties: MoveAIResult[];
retargeted: boolean;
last_frame: number;
private isMMD: boolean = false;
private obj: MMDAnimationHelperMixer | undefined;
private helper: MMDAnimationHelper | undefined;
private mmd_anim_clip: THREE.AnimationClip | undefined;
private pose: Object | undefined;
private processingClip: boolean = false;
private mmd_url: string = "";
constructor(
version: number,
media_id: string,
location: "glb" | "remote",
object_uuid: string,
speed: number,
length: number,
clip_name: string,
startTime: number,
duration: number,
) {
this.version = version;
this.media_id = media_id;
this.type = "animation";
this.object_uuid = object_uuid;
this.location = location;
this.speed = speed;
this.length = length;
this.startTime = startTime;
this.duration = duration;
this.clip_name = clip_name;
this.animation_clip;
this.mixer;
this.clip_action;
this.special_properties = [];
this.retargeted = false;
this.last_frame = 0;
}
get endTime() {
return this.startTime + this.duration
}
evaluate: (time: number) => void;
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
_load_animation(): Promise<THREE.AnimationClip> {
// Return the promise chain starting from `this.get_media_url()`
return this.get_media_url().then((url) => {
// Return a new Promise that resolves with the animation clip
return new Promise((resolve) => {
if (url.includes(".glb")) {
const glbLoader = new GLTFLoader();
glbLoader.load(url, (gltf) => {
// Assuming the animation is the first one in the animations array
const animationClip = gltf.animations[0];
resolve(animationClip);
});
} else if (url.includes(".fbx")) {
const fbxLoader = new FBXLoader();
fbxLoader.load(url, (fbx) => {
const animationClip = fbx.animations[0];
this.retargeted = true;
animationClip.tracks.forEach((track) => {
const retarget = new Retarget();
const retarget_value = retarget.retarget(track.name);
track.name = retarget_value.bone;
console.log(track);
if (retarget_value.is_special) {
this.special_properties.push(retarget_value);
}
});
resolve(animationClip);
});
} else if (url.includes(".vmd")) {
console.log("VMD Loader");
const root = this.mixer?.getRoot();
this.isMMD = true;
if (root) {
const mmdLoader = new MMDLoader();
this.mmd_url = url;
mmdLoader.loadAnimation(url, root as THREE.SkinnedMesh, (mmd) => {
mmd.name = this.mmd_url;
resolve(mmd as THREE.AnimationClip);
});
console.log("Loaded");
}
console.log("Dont");
}
});
});
}
_create_mixer(object: THREE.Object3D) {
this.mixer = new THREE.AnimationMixer(object);
return this.mixer;
}
async _get_clip() {
if (this.animation_clip == null && this.mixer !== null) {
console.log(this.media_id);
if (this.media_id === "SelfClip") {
const rootObject = this.mixer?.getRoot();
if (rootObject)
this.animation_clip = (
rootObject as THREE.Object3D<THREE.Object3DEventMap>
).animations[0];
} else {
this.animation_clip = await this._load_animation();
}
}
return this.animation_clip;
}
async play(object: THREE.Object3D) {
if (this.mixer == null) {
this.mixer = this._create_mixer(object);
const anim_clip = await this._get_clip();
if (anim_clip == undefined) {
return;
}
this.clip_action = this.mixer?.clipAction(anim_clip);
if (this.clip_action && this.isMMD === false) {
if (this.clip_action?.isRunning() == false) {
this.clip_action.play();
}
}
}
}
update_bones() {
if (this.retargeted === false) {
return;
}
const rootObject = this.mixer?.getRoot();
if (rootObject)
for (
let index_ = 0;
index_ <
(rootObject as THREE.Object3D<THREE.Object3DEventMap>).children.length;
index_++
) {
const child_holder = (
rootObject as THREE.Object3D<THREE.Object3DEventMap>
).children[index_];
if (child_holder.type == "Bone") {
child_holder.traverse(
(bone: THREE.Object3D<THREE.Object3DEventMap>) => {
for (
let index__ = 0;
index__ < this.special_properties.length;
index__++
) {
const property = this.special_properties[index__];
if (property.bone == bone.name + ".quaternion") {
const quat_y = THREE.MathUtils.degToRad(property.y);
bone.rotateY(quat_y);
if (property.only_y == false) {
const quat_x = THREE.MathUtils.degToRad(property.x);
bone.rotateX(quat_x);
const quat_z = THREE.MathUtils.degToRad(property.z);
bone.rotateZ(quat_z);
} else {
if (child_holder.parent) {
child_holder.parent.rotation.x =
THREE.MathUtils.degToRad(-90);
}
}
if (property.flip) {
bone.rotateX(bone.rotation.x * 2 * -1);
}
} else if (property.bone == bone.name + ".position") {
bone.position.set(
bone.position.x * property.x,
bone.position.y * property.y,
bone.position.z * property.z,
);
}
}
},
);
}
}
}
private async load_vpd_wrapped(): Promise<object> {
return new Promise((resolve) => {
const poseLoader = new MMDLoader();
poseLoader.loadVPD("/resources/pose/7.vpd", false, (vpd) => {
resolve(vpd);
}); // End of loadVPD call
}); // End of new Promise
}
async animate(deltatime: number) {
// Sets the frame of the animation mixer.
this.mixer?.setTime(deltatime);
// Regtargeting for Move.AI
this.update_bones();
// Next
if (this.isMMD && this.mixer) {
if (this.obj === undefined) {
// Sets the ik helper and object for IK and animation.
this.helper = (this.mixer.getRoot() as THREE.Object3D).parent?.userData[
"helper"
];
}
this.obj = this.helper?.objects.get(
this.mixer?.getRoot() as THREE.SkinnedMesh,
);
const mesh = this.mixer?.getRoot() as THREE.SkinnedMesh;
if (this.helper) {
this.helper._restoreBones(mesh); // Privateish js function for resetting bones.
this.obj?.mixer?.setTime(deltatime); // Sets the time.
this.helper._saveBones(mesh); // Saves the bones location for restoration later.
mesh.updateMatrixWorld(true); // Updates mesh.
this.obj?.ikSolver.update(); // Updates IK Solver.
this.obj?.grantSolver.update(); // Updates Grant which moves the bones.
}
// Changing of animation.
if (
this.obj &&
this.helper &&
this.processingClip === false &&
this.obj.mixer &&
this.mixer._actions[0]._clip.name !==
this.obj.mixer._actions[0]._clip.name &&
this.mixer !== undefined
) {
// On animation change.
this.processingClip = true;
const helper_mmd = this.helper.objects.get(mesh);
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
this.helper.remove(mesh);
mesh.pose();
const loader = new MMDLoader();
loader.loadAnimation(this.mmd_url, mesh, (mmd) => {
mmd.name = this.mmd_url;
this.helper?.add(mesh, {
animation: mmd,
physics: false,
});
this.processingClip = false;
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
this.obj.mixer = this.mixer;
});
}
}
}
async step(deltatime: number, isPlaying: boolean, frame: number) {
if (this.mixer == null) {
return;
}
if (this.retargeted) {
if (isPlaying || Math.floor(frame) != this.last_frame) {
if (Math.floor(frame) != 0) {
await this.animate(deltatime);
}
}
} else {
await this.animate(deltatime);
}
this.last_frame = frame;
}
stop() {
this.mixer?.setTime(0);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
object_uuid: this.object_uuid,
type: this.type,
speed: this.speed,
length: this.length,
clip_name: this.clip_name,
};
}
}
@@ -1,69 +0,0 @@
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
}
class AudioData implements AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
source: AudioBufferSourceNode | undefined;
constructor(audioContext: AudioContext, audioBuffer: AudioBuffer) {
this.audioContext = audioContext;
this.audioBuffer = audioBuffer;
}
}
export class AudioClip implements Ijson {
version: number;
media_id: string;
type: "audio";
volume: number;
audio_data: AudioData | undefined;
constructor(version: number, media_id: string, volume: number) {
this.version = version;
this.media_id = media_id;
this.type = "audio";
this.volume = volume;
this.download_audio().then((data) => {
this.audio_data = data;
});
}
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_audio() {
const url = await this.get_media_url();
const audioContext = new AudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return new AudioData(audioContext, audioBuffer);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
volume: this.volume,
};
}
}
@@ -1,71 +0,0 @@
// Clip offsets represent the state of the clip on the timeline as well as what type of clip it is.
// it is created from a media id.
import { MediaFileType } from "~/pages/PageEnigma/enums";
import { ClipGroup, ClipType } from "~/enums";
import Ijson from "~/interfaces/Ijson";
export class ClipUI implements Ijson {
version: number;
type: ClipType;
group: ClipGroup;
name: string;
media_id: string;
object_uuid: string;
object_name: string;
offset: number; // in frames
length: number; // in frames
should_play: boolean;
clip_uuid: string;
keyframe_offset: number;
media_file_type: MediaFileType;
constructor(
version: number,
type: ClipType,
group: ClipGroup,
name: string,
media_id: string,
clip_uuid: string,
object_uuid: string,
object_name: string,
offset: number,
length: number,
keyframe_offset: number = 0,
media_file_type: MediaFileType,
) {
this.version = version;
this.group = group; // Only needed for UI
this.clip_uuid = clip_uuid;
this.name = name; // UI
this.type = type; // UI and Animation / Audio / Lipsync / : Engine
this.object_uuid = object_uuid; // Animation / Audio / Lipsync / : Engine
this.object_name = object_name;
this.media_id = media_id; // Animation / Audio / Lipsync / : Engine
this.offset = offset;
this.length = length;
this.should_play = true;
this.keyframe_offset = keyframe_offset;
this.media_file_type = media_file_type;
}
toJSON() {
return {
version: this.version,
group: this.group,
name: this.name,
type: this.type,
clip_uuid: this.clip_uuid,
object_uuid: this.object_uuid,
object_name: this.object_name,
media_id: this.media_id,
start_offset: this.offset,
ending_offset: this.length,
keyframe_offset: this.keyframe_offset,
media_file_type: this.media_file_type,
};
}
}
@@ -1,143 +0,0 @@
import * as THREE from "three";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface CsvJson {
[key: string]: string[];
}
export class EmotionClip implements Ijson {
version: number;
media_id: string;
type: "expression";
emotion_json: any;
faces: THREE.Mesh[];
constructor(version: number, media_id: string) {
this.version = version;
this.media_id = media_id;
this.type = "expression";
this.faces = [];
this.download_csv().then((data) => {
this.emotion_json = data;
});
}
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_json() {
const url = await this.get_media_url();
const response = await fetch(url);
return await response.json();
}
async download_csv(): Promise<CsvJson> {
const url = await this.get_media_url();
const response = await fetch(url);
const csvText = await response.text();
// Parsing the CSV text
const rows = csvText.trim().split("\n");
const header = rows[0].split(",");
const jsonData: CsvJson = {};
header.forEach((key) => (jsonData[key] = []));
for (let i = 1; i < rows.length; i++) {
const values = rows[i].split(",");
values.forEach((value, index) => {
jsonData[header[index]].push(value);
});
}
return jsonData;
}
async _detect_face(object: THREE.Object3D): Promise<THREE.Mesh> {
this.faces = [];
return new Promise((resolve) => {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
const blendShapeIndexE = c.morphTargetDictionary["jawOpen"];
if (blendShapeIndexE !== undefined) {
this.faces.push(c);
resolve(c);
}
}
}
});
});
}
setBlends(shapes: { [key: string]: number }) {
this.faces.forEach((element: THREE.Mesh) => {
if (element.morphTargetInfluences && shapes) {
Object.keys(shapes).forEach((key) => {
let index = element.morphTargetDictionary?.[key];
if (index === undefined) {
index =
element.morphTargetDictionary?.[
key.charAt(0).toLowerCase() + key.slice(1)
];
}
if (
typeof index === "number" &&
element.morphTargetInfluences !== undefined
) {
element.morphTargetInfluences[index] = shapes[key];
}
});
}
});
}
async reset(object: THREE.Object3D) {
await this._detect_face(object);
const keys: { [key: string]: number } = {};
if (this.emotion_json === undefined || this.emotion_json === null) {
return;
}
Object.keys(this.emotion_json).forEach((key) => {
keys[key] = 0;
});
this.setBlends(keys);
}
async step(frame: number, object: THREE.Object3D) {
if (this.faces.length <= 0) {
await this._detect_face(object);
}
if (this.emotion_json === undefined || this.emotion_json === null) {
return;
}
const keys: { [key: string]: number } = {};
Object.keys(this.emotion_json).forEach((key) => {
if (Math.floor(frame) > this.emotion_json[key].length) {
return;
}
keys[key] = this.emotion_json[key][Math.floor(frame)];
});
this.setBlends(keys);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
};
}
}
@@ -1,171 +0,0 @@
import { LipSync } from "../Editor/lipsync";
import * as THREE from "three";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
}
class AudioData implements AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
source: AudioBufferSourceNode | undefined;
constructor(audioContext: AudioContext, audioBuffer: AudioBuffer) {
this.audioContext = audioContext;
this.audioBuffer = audioBuffer;
}
}
class BlendShapeHelper {
ah: number;
ee: number;
oh: number;
constructor(ah: number, ee: number, oh: number) {
this.ah = ah;
this.ee = ee;
this.oh = oh;
}
}
export class LipSyncClip implements Ijson {
version: number;
media_id: string;
type: "lipsync";
volume: number;
audio_data: AudioData | undefined;
lipsync: LipSync;
blendshape_helper: BlendShapeHelper;
faces: THREE.Mesh[];
constructor(version: number, media_id: string, volume: number) {
this.version = version;
this.media_id = media_id;
this.type = "lipsync";
this.volume = volume;
this.download_audio().then((data) => {
this.audio_data = data;
});
this.blendshape_helper = new BlendShapeHelper(0, 0, 0);
// we might need 3 of these one for each character ...
this.lipsync = new LipSync();
this.faces = [];
}
// lip sync will be generated through TTS
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
console.log(`API BASE URL? ${apiSchemeAndHost}`);
console.log(`CALLED URL? ${url}`);
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
//const media_base_url = `${media_api_base_url}/vocodes-public`;
//const media_url = `${media_base_url}${bucketPath}`;
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_audio() {
const url = await this.get_media_url();
const audioContext = new AudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return new AudioData(audioContext, audioBuffer);
}
async _detect_face(object: THREE.Object3D): Promise<THREE.Mesh> {
return new Promise((resolve) => {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
let blendShapeIndexE = c.morphTargetDictionary["E"];
let blendShapeIndexO = c.morphTargetDictionary["O"];
let blendShapeIndexA = c.morphTargetDictionary["aa"];
if (blendShapeIndexE === undefined) {
blendShapeIndexE = c.morphTargetDictionary["お"]; // MMD OH
blendShapeIndexO = c.morphTargetDictionary["ω"]; // MMD O
blendShapeIndexA = c.morphTargetDictionary["あ"]; // MMD A
}
if (blendShapeIndexE != null) {
this.blendshape_helper = new BlendShapeHelper(
blendShapeIndexA,
blendShapeIndexE,
blendShapeIndexO,
);
this.faces.push(c);
resolve(c);
}
}
}
});
});
}
async play(object: THREE.Object3D) {
if (this.audio_data?.audioBuffer == null) {
await this.download_audio();
}
if (this.lipsync.face == null) {
this.lipsync = new LipSync(await this._detect_face(object));
this.lipsync.startLipSyncFromAudioBuffer(this.audio_data?.audioBuffer);
}
}
stop() {
if (this.lipsync == null) {
return;
}
this.lipsync.destroy();
}
setBlends(ah: number, ee: number, oh: number) {
this.faces.forEach((element: THREE.Mesh) => {
if (element.morphTargetInfluences) {
element.morphTargetInfluences[this.blendshape_helper.ee] = ee;
element.morphTargetInfluences[this.blendshape_helper.ah] = ah;
element.morphTargetInfluences[this.blendshape_helper.oh] = oh;
}
});
}
step(
frame: number,
offset: number,
// rendering: boolean
) {
if (this.lipsync == null) {
return;
}
const positions = this.lipsync.update(frame, offset, true);
if (positions)
this.setBlends(positions["ee"], positions["ah"], positions["oh"]);
}
reset() {
if (this.lipsync.face != undefined) {
this.setBlends(0, 0, 0);
}
this.lipsync = new LipSync();
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
volume: this.volume,
};
}
}
@@ -1,238 +0,0 @@
import * as THREE from "three";
import Scene from "../Editor/scene";
export class TransformFrame {
position: THREE.Vector3;
rotation: THREE.Vector3;
scale: THREE.Vector3;
offset: number;
keyframe_uuid: string;
constructor(
position: THREE.Vector3,
rotation: THREE.Vector3,
scale: THREE.Vector3,
offset: number,
keyframe_uuid: string,
) {
this.position = position;
this.rotation = rotation;
this.scale = scale;
this.offset = offset;
this.keyframe_uuid = keyframe_uuid;
}
}
export class TransformClip {
version: number;
media_id: string;
object_uuid: string;
type: "transform";
keyframes: TransformFrame[];
length: number;
step_frame: number;
looping: boolean;
offset: number;
constructor(
version: number,
object_uuid: string,
length: number,
media_id: string = "",
) {
this.version = version;
this.media_id = media_id;
this.object_uuid = object_uuid;
this.type = "transform";
this.length = length;
this.keyframes = [];
this.step_frame = 0;
this.offset = 0;
this.looping = false;
}
private findNextNumber(location: number) {
// Sort the transform frames based on their offset in ascending order
this.keyframes.sort((a, b) => a.offset - b.offset);
let currentKeyframe: TransformFrame | undefined;
let nextKeyframe: TransformFrame | undefined;
this.length =
this.keyframes[this.keyframes.length - 1].offset + this.offset;
for (const frame of this.keyframes) {
if (frame.offset <= location) {
currentKeyframe = frame;
} else if (frame.offset > location) {
nextKeyframe = frame;
break;
}
}
return { currentKeyframe, nextKeyframe };
}
public setOffset(keyframe_uuid: string, keyframe_offset: number) {
this.keyframes.forEach((item) => {
if (item.keyframe_uuid === keyframe_uuid) {
item.offset = keyframe_offset;
return;
}
});
}
public setTransform(
keyframe_uuid: string,
position: THREE.Vector3,
rotation: THREE.Vector3,
scale: THREE.Vector3,
) {
this.keyframes.forEach((item) => {
if (item.keyframe_uuid === keyframe_uuid) {
item.position = position;
item.rotation = rotation;
item.scale = scale;
return;
}
});
}
public removeKeyframe(keyframe_uuid: string) {
this.keyframes = this.keyframes.filter(
(item) => item.keyframe_uuid !== keyframe_uuid,
);
}
request_point_location(keyframe_uuid: string, scene: Scene) {
const point = scene.getPoint(keyframe_uuid);
if (point == undefined) {
console.log("Could not find point.");
return;
}
this.keyframes.forEach((keyframe) => {
if (keyframe.keyframe_uuid == keyframe_uuid) {
keyframe.position = point.position;
keyframe.rotation = new THREE.Vector3(
point.rotation.x,
point.rotation.y,
point.rotation.z,
);
keyframe.scale = point.scale;
}
});
return point;
}
step(object: THREE.Object3D, offset: number, frame: number, scene: Scene) {
this.offset = offset;
this.step_frame = frame - offset;
//if (this.step_frame < 0) { this.step_frame = 0; }
//if (this.step_frame >= this.length) { return; } // Reached max frames.
if (this.keyframes.length < 1) {
return;
} // If there are enough points in the scene.
// Find the current and next keyframes based on time_frame
const { currentKeyframe, nextKeyframe } = this.findNextNumber(
this.step_frame,
);
if (nextKeyframe != undefined && currentKeyframe != undefined) {
const small_step_frame = this.step_frame - currentKeyframe.offset;
let location =
small_step_frame / (nextKeyframe.offset - currentKeyframe.offset);
if (location < 0) {
location = 0;
}
const pos_s = currentKeyframe?.position;
let pos_e = nextKeyframe?.position;
if (pos_e === undefined) {
this.request_point_location(nextKeyframe.keyframe_uuid, scene);
pos_e = nextKeyframe?.position;
}
const points_pos = [
new THREE.Vector3(pos_s.x, pos_s.y, pos_s.z),
new THREE.Vector3(pos_e.x, pos_e.y, pos_e.z),
];
const curve_pos = new THREE.CatmullRomCurve3(points_pos);
const point_pos = curve_pos.getPoint(location);
object.position.copy(point_pos);
const rot_s = currentKeyframe?.rotation;
const rot_e = nextKeyframe?.rotation;
const quat_s = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
THREE.MathUtils.degToRad(rot_s.x),
THREE.MathUtils.degToRad(rot_s.y),
THREE.MathUtils.degToRad(rot_s.z),
),
);
const quat_e = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
THREE.MathUtils.degToRad(rot_e.x),
THREE.MathUtils.degToRad(rot_e.y),
THREE.MathUtils.degToRad(rot_e.z),
),
);
const quaternion = new THREE.Quaternion();
quaternion.slerpQuaternions(quat_s, quat_e, location);
object.quaternion.copy(quaternion);
const scale_s = currentKeyframe?.scale;
const scale_e = nextKeyframe?.scale;
const points_scale = [
new THREE.Vector3(scale_s.x, scale_s.y, scale_s.z),
new THREE.Vector3(scale_e.x, scale_e.y, scale_e.z),
];
const curve_scale = new THREE.CatmullRomCurve3(points_scale);
const point_scale = curve_scale.getPoint(location);
object.scale.copy(point_scale);
}
}
reset(object: THREE.Object3D) {
if (this.keyframes.length > 0) {
const first_pos = this.keyframes[0].position;
const first_rot = this.keyframes[0].rotation;
const first_quat = new THREE.Euler(
THREE.MathUtils.degToRad(first_rot.x),
THREE.MathUtils.degToRad(first_rot.y),
THREE.MathUtils.degToRad(first_rot.z),
);
const first_scl = this.keyframes[0].scale;
object.position.copy(first_pos);
object.rotation.copy(first_quat);
object.scale.copy(first_scl);
this.step_frame = 0;
}
}
add_frame(
position: THREE.Vector3,
rotation: THREE.Vector3,
scale: THREE.Vector3,
offset: number,
keyframe_uuid: string,
) {
this.keyframes.push(
new TransformFrame(position, rotation, scale, offset, keyframe_uuid),
);
}
toJSON(): any {
return {
version: this.version,
media_id: this.media_id,
object_uuid: this.object_uuid,
length: this.length,
type: this.type,
keyframes: this.keyframes,
};
}
}
@@ -1,106 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import {
faRectangle,
faRectangleVertical,
faSquare,
} from "@fortawesome/pro-solid-svg-icons";
import { CameraAspectRatio } from "~/pages/PageEnigma/enums";
import { cameraAspectRatio } from "~/pages/PageEnigma/signals";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { ButtonDropdown } from "@storyteller/ui-button-dropdown";
export const AspectRatioMenu = () => {
useSignals();
const buttonIcon =
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_16_9
? faRectangle
: cameraAspectRatio.value === CameraAspectRatio.VERTICAL_9_16
? faRectangleVertical
: cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_3_2
? faRectangle
: cameraAspectRatio.value === CameraAspectRatio.VERTICAL_2_3
? faRectangleVertical
: faSquare;
const buttonText =
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_16_9
? "16:9 Horizontal"
: cameraAspectRatio.value === CameraAspectRatio.VERTICAL_9_16
? "9:16 Vertical"
: cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_3_2
? "3:2 Horizontal"
: cameraAspectRatio.value === CameraAspectRatio.VERTICAL_2_3
? "2:3 Vertical"
: "1:1 Squared";
const handleChangeAspectRatio = (newRatio: CameraAspectRatio) => {
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
data: newRatio,
});
};
return (
<div className="absolute right-0 top-0 m-2 flex flex-col items-end">
<ButtonDropdown
label={`${buttonText}`}
className="shadow-xl"
icon={buttonIcon}
align="right"
showSelected={true}
options={[
{
label: "16:9",
icon: faRectangle,
className: "pl-4",
description: "Horizontal",
selected:
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_16_9,
onClick: () =>
handleChangeAspectRatio(CameraAspectRatio.HORIZONTAL_16_9),
},
{
label: "3:2",
icon: faRectangle,
className: "pl-4",
description: "Horizontal",
selected:
cameraAspectRatio.value === CameraAspectRatio.HORIZONTAL_3_2,
onClick: () =>
handleChangeAspectRatio(CameraAspectRatio.HORIZONTAL_3_2),
},
{
label: "2:3",
icon: faRectangleVertical,
className: "pl-4",
description: "Vertical",
selected:
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_2_3,
onClick: () =>
handleChangeAspectRatio(CameraAspectRatio.VERTICAL_2_3),
},
{
label: "9:16",
icon: faRectangleVertical,
className: "pl-4",
description: "Vertical",
selected:
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_9_16,
onClick: () =>
handleChangeAspectRatio(CameraAspectRatio.VERTICAL_9_16),
},
{
label: "1:1",
icon: faSquare,
className: "pl-4",
description: "Squared",
selected: cameraAspectRatio.value === CameraAspectRatio.SQUARE_1_1,
onClick: () =>
handleChangeAspectRatio(CameraAspectRatio.SQUARE_1_1),
},
]}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from "./AssetMenu";
@@ -1,230 +0,0 @@
import { Modal } from "@storyteller/ui-modal";
import { faPlus, faTrashAlt } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "@storyteller/ui-popover";
import { Input } from "@storyteller/ui-input";
import { Label } from "@storyteller/ui-label";
import { Button } from "@storyteller/ui-button";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { focalLengthDragging } from "~/pages/PageEnigma/signals/camera";
import { useState, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@storyteller/ui-tooltip";
interface ExtendedPopoverItem extends PopoverItem {
id: string;
focalLength: number;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
lookAt: { x: number; y: number; z: number };
}
interface CameraSettingsModalProps {
isOpen: boolean;
onClose: () => void;
cameras: ExtendedPopoverItem[];
onCameraNameChange: (id: string, newName: string) => void;
onCameraFocalLengthChange: (id: string, value: number) => void;
onAddCamera: () => void;
selectedCameraId: string;
handleCameraSelect: (selectedItem: PopoverItem) => void;
onDeleteCamera: (id: string) => void;
}
export const CameraSettingsModal = ({
isOpen,
onClose,
cameras,
onCameraNameChange,
onCameraFocalLengthChange,
onAddCamera,
selectedCameraId,
handleCameraSelect,
onDeleteCamera,
}: CameraSettingsModalProps) => {
const selectedCamera = cameras.find((cam) => cam.id === selectedCameraId);
const [isDragging, setIsDragging] = useState(false);
const [tempCameraName, setTempCameraName] = useState<string>("");
const [editingCameraId, setEditingCameraId] = useState<string | null>(null);
useEffect(() => {
if (selectedCamera) {
setTempCameraName(selectedCamera.label);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera?.id]);
useEffect(() => {
const handlePointerUp = () => {
setIsDragging(false);
focalLengthDragging.value = {
isDragging: false,
focalLength: selectedCamera?.focalLength || 35,
};
};
document.addEventListener("pointerup", handlePointerUp);
return () => document.removeEventListener("pointerup", handlePointerUp);
}, [selectedCamera?.focalLength]);
const handlePointerDown = () => {
setIsDragging(true);
focalLengthDragging.value = {
isDragging: true,
focalLength: selectedCamera?.focalLength || 35,
};
};
const handleFocalLengthChange = (id: string, value: number) => {
focalLengthDragging.value = { isDragging: true, focalLength: value };
onCameraFocalLengthChange(id, value);
};
const handleNameBlur = () => {
if (selectedCamera && tempCameraName.trim() !== "") {
onCameraNameChange(selectedCamera.id, tempCameraName);
} else if (selectedCamera) {
setTempCameraName(selectedCamera.label);
}
setEditingCameraId(null);
};
const handleNameFocus = (id: string) => {
setEditingCameraId(id);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[500px] max-w-3xl duration-200",
isDragging ? "opacity-10" : "opacity-100",
)}
backdropClassName={twMerge(
"duration-200",
isDragging ? "opacity-0" : "opacity-100",
)}
childPadding={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:dark:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Camera</h2>
<Tooltip content="Add camera" position="top" delay={200}>
<button
className="h-6 w-6 rounded-full text-white/70 transition-colors hover:text-white/100"
onClick={onAddCamera}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-1">
{cameras.map((camera) => (
<button
key={camera.id}
className={`h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
camera.id === selectedCameraId ? "bg-[#63636B]/40" : ""
}`}
onClick={() => handleCameraSelect(camera)}
>
<div className="flex items-center gap-2.5 text-sm">
{camera.icon}
{editingCameraId === camera.id
? tempCameraName
: camera.label}
</div>
</button>
))}
</div>
</div>
<div className="col-span-8 p-3 ps-0 pt-2">
<div className="flex h-full flex-col">
<div>
<div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100">
<h2 className="text-[18px] font-semibold">
{selectedCamera?.label || "Camera"}
</h2>
<Tooltip
content={
selectedCamera?.id === "main"
? "Cannot delete the main camera"
: "Delete camera"
}
position="top"
delay={200}
>
<button
className={`h-6 w-6 rounded-lg transition-colors ${
selectedCamera?.id === "main"
? "cursor-not-allowed text-white/30"
: "text-white/60 hover:text-white/100"
}`}
onClick={() =>
selectedCamera &&
selectedCamera.id !== "main" &&
onDeleteCamera(selectedCamera.id)
}
disabled={selectedCamera?.id === "main"}
>
<FontAwesomeIcon icon={faTrashAlt} className="text-lg" />
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="camera-name" className="text-sm opacity-70">
Name
</Label>
<Input
id="camera-name"
type="text"
value={tempCameraName}
onChange={(e) => setTempCameraName(e.target.value)}
onBlur={handleNameBlur}
onFocus={() =>
selectedCamera && handleNameFocus(selectedCamera.id)
}
className="text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="focal-length" className="text-sm opacity-70">
Focal Length
</Label>
<div
className="mt-1 flex items-center gap-4"
onPointerDown={handlePointerDown}
>
<SliderV2
min={10}
max={200}
value={selectedCamera?.focalLength || 35}
onChange={(value) =>
selectedCamera &&
handleFocalLengthChange(selectedCamera.id, value)
}
step={1}
suffix="mm"
showDecrement={true}
showIncrement={true}
className="w-full"
/>
<span className="min-w-[60px] text-sm">
{selectedCamera?.focalLength || 35}mm
</span>
</div>
</div>
</div>
</div>
<div className="mt-auto flex justify-end pt-4">
<Button onClick={onClose}>Done</Button>
</div>
</div>
</div>
</div>
</Modal>
);
};
@@ -1 +0,0 @@
export { CameraSettingsModal } from "./CameraSettingsModal";
@@ -1 +0,0 @@
export { Controls3D } from './Controls3D';
@@ -1,2 +0,0 @@
export { ScenePicker } from "./ScenePicker";
export type { SceneTypes } from "./ScenePicker";
@@ -1,74 +0,0 @@
//TODO: DELETE
import { useContext } from "react";
import { Button } from "@storyteller/ui-button"
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
export const TestFeaturesButtons = ({ debug }: { debug: boolean }) => {
if (!debug) return null;
const editorEngine = useContext(EngineContext);
const handleButtonCameraView = () => {
editorEngine?.switchCameraView();
};
const testStylizeRequest = () => {
// editorEngine?.testStylizeRequest();
//console.log("editorEnging does not have testStylizeRequest");
};
const handleButtonRender = () => {
editorEngine?.generateVideo();
};
const handleButtonTakeFrame = () => {
// editorEngine?.take_timeline_cam_clip();
//console.log("editorEnging does not have take_timeline_cam_clip");
};
const handleButtonSingleFrame = () => {
editorEngine?.generateFrame();
};
const handleButtonPlayBack = () => {
editorEngine?.togglePlayback();
};
const smallButtons = "text-xs p-2 h-6 ";
const tertiaryColor =
"bg-brand-tertiary hover:bg-brand-teriary-400 focus-visible:outline-brand-tertiary ";
return (
<>
<div className="flex gap-1">
<Button
variant="secondary"
onClick={handleButtonCameraView}
className={smallButtons}
>
Toggle Camera View
</Button>
</div>
<div className="flex gap-1">
<Button onClick={handleButtonSingleFrame} className={smallButtons}>
Render Single Frame
</Button>
<Button onClick={handleButtonTakeFrame} className={smallButtons}>
Take Frame
</Button>
<Button onClick={handleButtonRender} className={smallButtons}>
Render
</Button>
<Button
onClick={testStylizeRequest}
className={
"hover:bg-brand-teriary-400 bg-brand-tertiary focus-visible:outline-brand-tertiary " +
smallButtons
}
style={{ zIndex: 9001 }}
>
Test Stylize
</Button>
</div>
</>
);
};
@@ -1,2 +0,0 @@
export { ControlsTopButtons } from './ControlsTopButtons';
@@ -1,158 +0,0 @@
import { useContext, useState } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import {
faBackwardStep,
faForwardStep,
faCirclePause,
faCirclePlay,
faPlay,
faRepeat,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIcon } from "@storyteller/ui-button-icon";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import {
currentTime,
filmLength,
secondaryScrubber,
} from "~/pages/PageEnigma/signals";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { editorState } from "~/pages/PageEnigma/signals/engine";
import { EditorStates } from "~/pages/PageEnigma/enums";
import { twMerge } from "tailwind-merge";
import { usePosthogFeatureFlag } from "~/hooks/usePosthogFeatureFlag";
import { FeatureFlags } from "~/enums";
export const ControlsVideo = () => {
useSignals();
const editorEngine = useContext(EngineContext);
const [refresh, setRefresh] = useState(0);
const showStylePage = usePosthogFeatureFlag(FeatureFlags.SHOW_STYLE_PAGE);
function formatTime(millis: number) {
const date = new Date(millis);
const secs = String(date.getUTCSeconds()).padStart(2, "0");
const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
return `00:${secs}:${ms}`;
}
const isPlaying =
editorEngine !== null ? editorEngine.timeline.is_playing : false;
const isRepeating =
editorEngine !== null ? editorEngine.timeline.is_repeating : false;
const handleToStart = () => {
currentTime.value = 0;
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.UPDATE_TIME,
data: { currentTime: currentTime.value },
});
};
const handleToEnd = () => {
currentTime.value = filmLength.value * 1000;
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.UPDATE_TIME,
data: { currentTime: currentTime.value },
});
};
const handleBackwardStep = () => {
currentTime.value = Math.max(currentTime.value - 1, 0);
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.UPDATE_TIME,
data: { currentTime: currentTime.value },
});
};
const handleRepeat = () => {
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.TOGGLE_REPEATING,
data: null,
});
setRefresh((refresh + 1) % 10);
};
const handlePlayback = () => {
editorEngine?.togglePlayback();
};
const handleForwardStep = () => {
currentTime.value = Math.min(
currentTime.value + 1,
filmLength.value * 1000,
);
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.UPDATE_TIME,
data: { currentTime: currentTime.value },
});
};
if (editorState.value === EditorStates.PREVIEW) {
return null;
}
return (
<div className="flex items-center justify-center">
<div className="flex items-center gap-4 rounded-t-lg bg-ui-controls p-1.5 text-white shadow-md">
<div className="flex items-center gap-1">
<div className="flex gap-0">
<ButtonIcon icon={faBackwardStep} onClick={handleToStart} />
<ButtonIcon
icon={faPlay}
onClick={handleBackwardStep}
className="rotate-180 text-sm"
/>
</div>
<ButtonIcon
icon={isPlaying ? faCirclePause : faCirclePlay}
onClick={handlePlayback}
className="p-0 text-2xl"
/>
<div className="flex gap-0">
<ButtonIcon
icon={faPlay}
onClick={handleForwardStep}
className="text-sm"
/>
<ButtonIcon icon={faForwardStep} onClick={handleToEnd} />
</div>
</div>
<div className="flex items-center gap-3">
<div className="mr-2 flex items-center gap-1.5 text-sm font-medium">
<span className="w-[54px]">
{formatTime(secondaryScrubber.value)}
</span>
<span className="opacity-50">/</span>
<span className="w-[54px] opacity-50">
{formatTime(filmLength.value * 1000)}
</span>
</div>
{showStylePage && (
<>
<div className="h-[18px] w-0.5 rounded-full bg-white/20" />
<Tooltip content="Loop" position={"top"}>
<ButtonIcon
icon={faRepeat}
onClick={handleRepeat}
className={twMerge(
"h-7 w-7 p-0 text-sm",
isRepeating
? "border border-brand-primary"
: "border border-transparent",
)}
/>
</Tooltip>
</>
)}
</div>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { ControlsVideo } from './ControlsVideo';
@@ -1,108 +0,0 @@
import {
canDrop,
currPosition,
dragItem,
overTimeline,
scale,
timelineHeight,
} from "~/pages/PageEnigma/signals";
import { useSignals } from "@preact/signals-react/runtime";
import DndAsset from "~/pages/PageEnigma/DragAndDrop/DndAsset";
import { MIN_ANIM_DURATION } from "~/constants";
export const DragComponent = () => {
useSignals();
if (!dragItem.value) {
return null;
}
const { currX, currY } = currPosition.value;
const thumbnail = dragItem.value.thumbnail
? dragItem.value.thumbnail
: `/resources/images/default-covers/${dragItem.value.imageIndex || 0}.webp`;
if (overTimeline.value) {
if (canDrop.value && DndAsset.overElement) {
return (
<>
<div
id={`ani-dnd-${dragItem.value.media_id}`}
className={[
"absolute p-2",
"rounded-md",
!canDrop.value
? "bg-brand-primary"
: "border border-dashed border-dnd-canDropBorder bg-dnd-canDrop",
"block",
].join(" ")}
style={{
top: DndAsset.overElement.top,
left: currX + 1,
zIndex: 10000,
width: Math.max(dragItem.value.length ?? 0, MIN_ANIM_DURATION) * 4 * scale.value,
height: 60,
}}
/>
<div
id={`ani-dnd-${dragItem.value.media_id}`}
className={[
"absolute p-2",
"rounded opacity-60",
"border border-dnd-timeGridBorder bg-dnd-timeGrid",
].join(" ")}
style={{
bottom: timelineHeight.value - 60,
left: currX + 1,
zIndex: 10000,
width: Math.max(dragItem.value.length ?? 0, MIN_ANIM_DURATION) * 4 * scale.value,
height: 16,
}}
/>
</>
);
}
return (
<div
id={`ani-dnd-${dragItem.value.media_id}`}
className={[
"absolute p-1",
"rounded-lg text-xs",
"bg-dnd-cannotDrop",
"block text-nowrap",
].join(" ")}
style={{
top: currY - 16,
left: currX + 1,
zIndex: 10000,
}}
>
{DndAsset.notDropText || "Cannot drop here"}
</div>
);
}
return (
<div
className="absolute rounded-lg"
style={{
width: 91,
height: 114,
top: currY - 57,
left: currX + 1,
zIndex: 10000,
}}
>
<img
{...{
crossOrigin: "anonymous",
src: thumbnail,
}}
alt={dragItem.value.name}
className="pointer-events-none select-none rounded-t-lg bg-gradient-to-b from-[#CCCCCC] to-[#A0A0A0]"
/>
<div className="w-full truncate rounded-b-lg bg-ui-controls px-2 py-1 text-center text-[12px]">
{dragItem.value.name || dragItem.value.media_id}
</div>
</div>
);
};
@@ -1,17 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { loadingBarData, loadingBarIsShowing } from "~/signals";
import { LoadingBar } from "@storyteller/ui-loading";
export const EditorLoadingBar = () => {
useSignals();
return (
<LoadingBar
id="editor-loading-bar"
show={loadingBarIsShowing.value}
wrapperClassName="absolute top-0 left-0 z-[80]"
innerWrapperClassName="max-w-screen-sm"
hasSpinner
progressData={loadingBarData.value}
/>
);
};
@@ -1,37 +0,0 @@
import { useCallback, useContext } from "react";
import { camViewCanvasSignal, editorCanvasSignal, EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
export const EditorCanvas = () => {
const canvasCallbackRef = useCallback(
(node: HTMLCanvasElement) => {
if (node) {
editorCanvasSignal.value = node;
}
},
[],
);
return (
<canvas
ref={canvasCallbackRef}
id="video-scene"
width="1280px"
height="720px"
/>
);
};
export const CameraViewCanvas = ({ className }: { className?: string }) => {
const canvasCallbackRef = useCallback(
(node: HTMLCanvasElement) => {
if (node) {
camViewCanvasSignal.value = node;
}
},
[],
);
return (
<canvas className={className} ref={canvasCallbackRef} id="camera-view" />
);
};
@@ -1,269 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faBell, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu } from "@storyteller/ui-popover";
import { CompletedCard } from "./CompletedCard";
import { InProgressCard } from "./InProgressCard";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useState, useRef, useCallback } from "react";
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { toast } from "@storyteller/ui-toaster";
import { ActiveJob } from "~/pages/PageEnigma/models";
import { IsJobPollingDisabled } from "~/flags/DevConsoleFlags";
// TODO ensure we de-dupe all this extra code.
interface CompletedItem {
token: string;
maybe_title: string;
public_bucket_path: string; // TODO: STOP USING THIS.
maybe_media_links_thumbnail?: string;
updated_at: string;
maybe_result?: {
entity_token?: string;
};
}
// {
// "job_token": "jinf_421nnr6rj8wy2sj0s11v9fns32s",
// "request": {
// "inference_category": "image_generation",
// "maybe_model_type": "image_gen_api",
// "maybe_model_token": null,
// "maybe_model_title": "Image Generation",
// "maybe_raw_inference_text": null,
// "maybe_style_name": null,
// "maybe_live_portrait_details": null,
// "maybe_lipsync_details": null
// },
// "status": {
// "status": "complete_success",
// "maybe_extra_status_description": null,
// "maybe_assigned_worker": "inference-job-4o-image-848c55d5dd-555kn",
// "maybe_assigned_cluster": "storyteller-eks-1",
// "maybe_first_started_at": "2025-04-15T04:41:01Z",
// "maybe_current_execution_duration_seconds": null,
// "attempt_count": 1,
// "requires_keepalive": false,
// "maybe_failure_category": null,
// "progress_percentage": 100
// },
// "maybe_result": {
// "entity_type": "media_file",
// "entity_token": "m_ytrjp1fcd5tb43ebcs3ewhq6y05tfb",
// "maybe_public_bucket_media_path": "/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "media_links": {
// "cdn_url": "https://cdn-2.fakeyou.com/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "maybe_thumbnail_template": "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/p/t/7/y/g/pt7yg2xfv4ynemssfv1mwb7yxe26e2nb/image_pt7yg2xfv4ynemssfv1mwb7yxe26e2nb.png",
// "maybe_video_previews": null
// },
// "maybe_successfully_completed_at": "2025-04-15T04:42:14Z"
// },
// "created_at": "2025-04-15T04:41:01Z",
// "updated_at": "2025-04-15T04:42:14Z"
// }
export function Activity() {
useSignals();
const [completedItems, setCompletedItems] = useState<CompletedItem[]>([]);
const [jobs, setJobs] = useState<ActiveJob[]>([]);
const processedJobs = useRef(new Set<string>());
const isFirstLoad = useRef(true);
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Debounced toast function
const showToast = useCallback((items: CompletedItem[]) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = setTimeout(() => {
const count = items.length;
toast.success(
count === 1
? `${items[0].maybe_title} completed successfully`
: `${count} images completed successfully`,
);
toastTimeoutRef.current = null;
}, 100); // Small delay to catch potential double calls
}, []);
useEffect(() => {
let mounted = true;
async function fetchJobs() {
if (IsJobPollingDisabled()) {
return;
}
try {
const jobsApi = new JobsApi();
const response = await jobsApi.ListRecentJobs();
if (!mounted || !response.success || !response.data) return;
// Handle active jobs
const activeJobs = response.data
.filter((job) => {
const status = job.status?.status?.toLowerCase();
return (
status && status !== "complete_success" && status !== "failed"
);
})
.map(
(job) =>
({
job_token: job.job_token,
request: {
maybe_model_title:
job.request?.maybe_model_title || "Image Generation",
},
status: {
status: job.status?.status?.toUpperCase() || "",
progress_percentage: job.status?.progress_percentage || 0,
},
created_at: job.created_at || new Date().toISOString(),
updated_at: job.updated_at || new Date().toISOString(),
maybe_result: job.maybe_result || null,
}) as ActiveJob,
);
// Handle completed jobs
const newlyCompleted = response.data.filter(
(job) =>
job.status?.status?.toLowerCase() === "complete_success" &&
!processedJobs.current.has(job.job_token),
);
if (newlyCompleted.length > 0) {
// Add to processed set
newlyCompleted.forEach((job) =>
processedJobs.current.add(job.job_token),
);
// Create completed items
const newItems = newlyCompleted.map((job) => ({
token: job.job_token,
maybe_title: job.request?.maybe_model_title || "Image Generation",
public_bucket_path:
job.maybe_result?.maybe_public_bucket_media_path || "",
maybe_media_links_thumbnail: ((job) => {
// Try the video thumbnail first, then the regular thumbnail.
const maybeVideoThumb =
job.maybe_result?.media_links?.maybe_video_previews?.animated;
const maybeGenericThumb = (
job.maybe_result?.media_links?.maybe_thumbnail_template || ""
).replace("{WIDTH}", "500");
return maybeVideoThumb || maybeGenericThumb;
})(job),
updated_at: job.updated_at || new Date().toISOString(),
maybe_result: job.maybe_result,
}));
// NB(bt,2025-07-28): Don't use eventing from here anymore.
// Tauri is sending direct events we can bind toasts to.
// // Show toast only if not first load
// if (!isFirstLoad.current) {
// showToast(newItems);
// }
// Update state
setCompletedItems((prev) => {
const combined = [...newItems, ...prev];
return combined
.filter(
(item, index, self) =>
index === self.findIndex((t) => t.token === item.token),
)
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, 50);
});
}
setJobs(activeJobs);
isFirstLoad.current = false;
} catch (error) {
console.error("Failed to fetch jobs:", error);
}
}
// Initial fetch
fetchJobs();
// Set up polling
const interval = setInterval(fetchJobs, 5000);
// Cleanup
return () => {
mounted = false;
clearInterval(interval);
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, [showToast]);
return (
<Tooltip content="Activity" position="bottom" closeOnClick={true}>
<PopoverMenu
mode="default"
buttonClassName="h-[38px] w-[38px] !p-0"
panelClassName="w-[360px] p-2 bg-ui-panel"
position="bottom"
align="end"
triggerIcon={
<div>
{jobs.length > 0 ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<FontAwesomeIcon icon={faBell} />
)}
{jobs.length > 0 && (
<div className="absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full bg-brand-primary-400" />
)}
</div>
}
>
{(close) => (
<div className="max-h-[480px] overflow-y-auto">
{jobs.length > 0 && (
<div>
{jobs.map((job) => (
<InProgressCard key={job.job_token} job={job} />
))}
</div>
)}
{isFirstLoad.current ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
size="2x"
className="text-base-fg/40"
/>
<h3 className="text-base-fg/60">Retrieving Activities</h3>
</div>
) : jobs.length === 0 && completedItems.length === 0 ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<h3 className="text-base-fg/60 text-lg">No activities yet</h3>
</div>
) : (
<div>
<div className="flex flex-col">
{completedItems.map((item) => (
<div key={item.token} onClick={close}>
<CompletedCard job={item} />
</div>
))}
</div>
</div>
)}
</div>
)}
</PopoverMenu>
</Tooltip>
);
}
@@ -1,90 +0,0 @@
import { useState } from "react";
import dayjs from "dayjs";
import {
galleryModalLightboxMediaId,
galleryModalLightboxVisible,
galleryModalLightboxImage,
} from "@storyteller/ui-gallery-modal";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
interface Props {
job: {
token: string;
maybe_title: string;
updated_at: string;
public_bucket_path: string;
maybe_style_name?: string;
maybe_media_links_thumbnail?: string;
maybe_result?: {
entity_token?: string;
media_links?: {
cdn_url?: string;
};
};
};
}
export function CompletedCard({ job }: Props) {
const [loadError, setLoadError] = useState(false);
// Info: Gallery modal will handle displaying full media. Compute thumbnail only.
// NB: Try to use the thumbnail instead of the link to the full asset.
// Passing around full asset URLs and putting them into image tags is causing CORS errors if those assets are not images.
const thumbnailUrl = job?.maybe_media_links_thumbnail;
if (!job) return null;
return (
<>
<div
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
onClick={() => {
galleryModalLightboxMediaId.value =
job.maybe_result?.entity_token || null;
galleryModalLightboxImage.value = {
id: job.maybe_result?.entity_token || job.token,
label: job.maybe_title || "Image",
thumbnail: thumbnailUrl,
fullImage: job.maybe_result?.media_links?.cdn_url || thumbnailUrl,
createdAt: job.updated_at,
mediaClass: undefined,
} as GalleryItem;
galleryModalLightboxVisible.value = true;
}}
>
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError
? "/resources/images/movie-placeholder.png"
: thumbnailUrl
}
className="h-full w-full object-cover"
alt={job.maybe_title ?? "unknown"}
//crossOrigin="anonymous"
onError={() => setLoadError(true)}
loading="lazy"
/>
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">{job.maybe_title || "Untitled"}</div>
<div className="text-sm text-white/60">
{dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")}
</div>
</div>
</div>
{/* <a
href={downloadLink}
download
onClick={(e) => e.stopPropagation()}
className="flex h-9 w-9 items-center justify-center text-lg text-white/60 transition-all hover:text-white"
>
<FontAwesomeIcon icon={faDownToLine} />
</a> */}
</div>
{/* LightboxModal removed – now using top level GalleryModal */}
</>
);
}
@@ -1,46 +0,0 @@
import { useCallback, useState } from "react";
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import { MediaFile } from "~/pages/PageEnigma/models";
import { generateMovieId } from "~/pages/PageEnigma/signals";
import { ToastTypes } from "~/enums";
import { addToast } from "~/signals";
import { MyMovies } from "~/pages/PageEnigma/comps/GenerateModals/MyMovies";
import { Sharing } from "~/pages/PageEnigma/comps/GenerateModals/Sharing";
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
export function GenerateModals() {
useSignals();
const [mediaFile, setMediaFile] = useState<MediaFile | null>(null);
const GetMediaFileByToken = useCallback(async (movieId: string) => {
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: movieId,
});
if (response.success && response.data) {
setMediaFile(response.data);
return;
}
addToast(
ToastTypes.ERROR,
response.errorMessage ||
`Unknown Error in Getting Movie (token=${movieId}`,
);
}, []);
const setMovieId = useCallback((movieId: string) => {
generateMovieId.value = movieId;
}, []);
useSignalEffect(() => {
if (generateMovieId.value) {
GetMediaFileByToken(generateMovieId.value);
}
});
if (!mediaFile) {
return <MyMovies setMovieId={setMovieId} />;
}
return <Sharing mediaFile={mediaFile!} setMediaFile={setMediaFile} />;
}
@@ -1,90 +0,0 @@
import { useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { ToastTypes } from "~/enums";
import { addToast } from "~/signals";
import { JobState } from "~/pages/PageEnigma/enums";
import { ActiveJob } from "~/pages/PageEnigma/models";
import { Tooltip } from "@storyteller/ui-tooltip";
import { JobsApi } from "~/Classes/ApiManager";
import { PollRecentJobs } from "~/hooks/useActiveJobs/utilities";
interface Props {
job: ActiveJob;
}
function getPercent(status: JobState, percentage: number) {
if (
[
JobState.COMPLETE_FAILURE,
JobState.ATTEMPT_FAILED,
JobState.DEAD,
].includes(status)
) {
return 0;
} else {
return percentage;
}
}
export function InProgressCard({ job }: Props) {
const completePercent = getPercent(
job.status.status as JobState,
job.status.progress_percentage,
);
const deleteJob = useCallback(async (job: ActiveJob) => {
const jobsApi = new JobsApi();
const response = await jobsApi.DeleteJobByToken(job.job_token);
if (response.success) {
PollRecentJobs();
addToast(ToastTypes.SUCCESS, "File successfully deleted.");
return;
}
addToast(
ToastTypes.ERROR,
response.errorMessage || "Error deleting the file.",
);
}, []);
return (
<div className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:bg-white/10">
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 items-center justify-center rounded-lg border border-[#A9A9A9]/50 bg-black/60 text-white">
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
{/* {(job.status.status === JobState.STARTED ||
job.status.status === JobState.PENDING) && (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
)}
{(job.status.status === JobState.COMPLETE_FAILURE ||
job.status.status === JobState.ATTEMPT_FAILED) && (
<FontAwesomeIcon icon={faCircleExclamation} size={"lg"} />
)} */}
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">
{job.request.maybe_model_title || "Image Generation"}
</div>
<div className="text-sm capitalize text-white/60">
{job.status.status.replaceAll("_", " ")}... {completePercent}%
</div>
</div>
</div>
{job.status.status !== JobState.STARTED && (
<div className="pr-5">
<Tooltip content="Cancel" position="left">
<button
onClick={() => deleteJob(job)}
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
>
<FontAwesomeIcon icon={faClose} />
</button>
</Tooltip>
</div>
)}
</div>
);
}
@@ -1,83 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faFilm, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CompletedCard } from "~/pages/PageEnigma/comps/GenerateModals/CompletedCard";
import { InProgressCard } from "~/pages/PageEnigma/comps/GenerateModals/InProgressCard";
import { Modal } from "@storyteller/ui-modal";
import { viewMyMovies } from "~/pages/PageEnigma/signals";
import { activeWorkflowJobs, userMovies } from "~/signals";
interface Props {
setMovieId: (page: string) => void;
}
export function MyMovies({ setMovieId }: Props) {
useSignals();
return (
<Modal
title="My Movies"
titleIcon={faFilm}
className="max-w-4xl"
childPadding={false}
isOpen={viewMyMovies.value}
onClose={() => {
setMovieId("");
viewMyMovies.value = false;
}}
>
<div className="h-[560px] overflow-y-auto overflow-x-hidden rounded-b-lg">
{activeWorkflowJobs.value && activeWorkflowJobs.value.length > 0 && (
<div className="mb-3">
<div className="mx-5 mb-1 font-medium">In Progress</div>
{activeWorkflowJobs.value.map((movieJob) => (
<InProgressCard key={movieJob.job_token} movie={movieJob} />
))}
</div>
)}
<MovieList setMovieId={setMovieId} />
</div>
</Modal>
);
}
const MovieList = ({ setMovieId }: Props) => {
useSignals();
if (!userMovies.value && !activeWorkflowJobs.value) {
return (
<div className="flex h-full w-full flex-col justify-center gap-6 text-center align-middle">
<FontAwesomeIcon icon={faSpinnerThird} spin size={"3x"} />
<h3>Retrieving Completed Movies</h3>
</div>
);
}
if (
userMovies.value &&
userMovies.value.length === 0 &&
!activeWorkflowJobs.value
) {
return (
<div className="flex h-full w-full flex-col justify-center gap-6 text-center align-middle">
<h3>You have not created any movies yet!</h3>
<p>
Try start a new scene from our featured scenes, and generate a movie
via the <b>AI Stylize</b> tab.
</p>
</div>
);
}
return (
<div>
<div className="mx-5 mb-1 font-medium">Completed</div>
<div className="flex flex-col">
{userMovies.value?.map((movie) => (
<CompletedCard
key={movie.token}
movie={movie}
setMovieId={setMovieId}
/>
))}
</div>
</div>
);
};
@@ -1,150 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { useState } from "react";
import { MediaFile } from "~/pages/PageEnigma/models";
import { Input } from "@storyteller/ui-input";
import { Label } from "@storyteller/ui-label";
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import {
faArrowDownToLine,
faArrowRight,
faChevronLeft,
faFilm,
faLink,
} from "@fortawesome/pro-solid-svg-icons";
import SocialButton from "./SocialButton";
import { generateMovieId, viewMyMovies } from "~/pages/PageEnigma/signals";
import dayjs from "dayjs";
import { downloadFile } from "~/pages/PageEnigma/comps/GenerateModals/utils/downloadFile";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
interface Props {
mediaFile: MediaFile;
setMediaFile: (file: MediaFile | null) => void;
}
export function Sharing({ mediaFile, setMediaFile }: Props) {
useSignals();
const mediaTitle = mediaFile?.maybe_title ?? mediaFile?.token;
// TODO: ApiManager should provide all endpoints
const shareUrl = `https://storyteller.ai/media/${mediaFile?.token || ""}`;
const shareText = "Check out this media on StoryTeller.ai";
const [buttonLabel, setButtonLabel] = useState("Copy");
const media_api_base_url = GetCdnOrigin();
const downloadLink = `${media_api_base_url}${mediaFile?.public_bucket_path}`;
const handleCopyLink = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(shareUrl);
}
setButtonLabel("Copied!");
setTimeout(() => setButtonLabel("Copy"), 2000);
};
const generateTitle = () => {
return (
<div className="flex items-center gap-2.5">
<span className="font-xl font-bold">{mediaTitle}</span>
<span className="text-sm font-medium text-white/60">
{dayjs(mediaFile?.updated_at).format("MMM DD, YYYY HH:mm:ss")}
</span>
</div>
);
};
return (
<Modal
title={generateTitle()}
titleIcon={generateMovieId.value ? faChevronLeft : faFilm}
titleIconClassName="text-white/60 hover:text-white/80 transition-colors duration-150"
onTitleIconClick={
generateMovieId.value ? () => setMediaFile(null) : undefined
}
className="max-w-6xl"
childPadding={false}
isOpen={viewMyMovies.value}
width={1049}
onClose={() => {
viewMyMovies.value = false;
setMediaFile(null);
}}
>
<div className="flex gap-6 px-5 pb-5">
<div className="max-h-[420px] w-full overflow-hidden rounded-lg">
<video controls crossOrigin="anonymous" className="h-full w-full">
<source src={downloadLink} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
<div className="flex w-[500px] flex-col">
<Label>Share movie to:</Label>
<div className="flex w-full flex-wrap justify-between">
<SocialButton
social="x"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="whatsapp"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="facebook"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="reddit"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="email"
shareUrl={shareUrl}
shareText={shareText}
/>
</div>
<div className="my-6 flex w-full gap-2">
<div className="w-full">
<Input type="text" value={shareUrl} readOnly />
</div>
<Button icon={faLink} onClick={handleCopyLink} variant="primary">
{buttonLabel}
</Button>
</div>
<div className="flex flex-col gap-3">
<Button
icon={faArrowDownToLine}
className="h-10 w-full"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
downloadFile({
url: downloadLink,
title: mediaTitle,
});
}}
variant="secondary"
>
Download
</Button>
<Button
className="h-10 w-full"
onClick={() => {
window.open(shareUrl, "_blank");
}}
icon={faArrowRight}
iconFlip={true}
variant="secondary"
>
View on Media Page
</Button>
</div>
</div>
</div>
</Modal>
);
}
@@ -1,82 +0,0 @@
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import {
faFacebookF,
faRedditAlien,
faWhatsapp,
faXTwitter,
} from "@fortawesome/free-brands-svg-icons";
import { faXmark, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface SocialButtonProps {
social: "x" | "whatsapp" | "facebook" | "reddit" | "email";
shareUrl: string;
shareText: string;
}
export default function SocialButton({
social,
shareUrl,
shareText,
}: SocialButtonProps) {
const socialIcons = {
x: faXTwitter,
whatsapp: faWhatsapp,
facebook: faFacebookF,
reddit: faRedditAlien,
email: faEnvelope,
};
const socialIcon = socialIcons[social] || faXmark;
const getShareLink = (social: string, url: string, text: string) => {
switch (social) {
case "x":
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
url,
)}&text=${encodeURIComponent(text)}`;
case "facebook":
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
url,
)}`;
case "whatsapp":
return `https://wa.me/?text=${encodeURIComponent(
text,
)} ${encodeURIComponent(url)}`;
case "reddit":
return `https://www.reddit.com/submit?url=${encodeURIComponent(
url,
)}&title=${encodeURIComponent(text)}`;
case "email":
return `mailto:?subject=${encodeURIComponent(
text,
)}&body=${encodeURIComponent(url)}`;
default:
return "#";
}
};
const handleClick = () => {
const shareLink = getShareLink(social, shareUrl, shareText);
window.open(shareLink, "_blank");
};
return (
<button className="flex flex-col items-center" onClick={handleClick}>
<div
className={[
"flex h-[56px] w-[56px]",
"items-center justify-center rounded-lg",
"text-[32px] text-white",
`bg-${social}`,
"transition-all duration-200 hover:opacity-80",
].join(" ")}>
<FontAwesomeIcon icon={socialIcon as IconProp} />
</div>
<p className="mt-2 text-xs">
{social.charAt(0).toUpperCase() + social.slice(1)}
</p>
</button>
);
}
@@ -1,51 +0,0 @@
import { ToastTypes } from "~/enums";
import { addToast } from "~/signals";
export const downloadFile = ({
url,
title,
}: {
url: string;
title: string;
}) => {
// console.log(url);
fetch(url)
.then((resp) => resp.blob())
.then((blob) => {
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = `${title}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch(() => addToast(ToastTypes.ERROR, "Could not download file."));
};
export const downloadFileImage = ({
url,
title,
}: {
url: string;
title: string;
}) => {
// console.log(url);
fetch(url)
.then((resp) => resp.blob())
.then((blob) => {
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = `${title}.png`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch(() => addToast(ToastTypes.ERROR, "Could not download file."));
};
//check
@@ -1,70 +0,0 @@
import React from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { poseMode, showPoseControls } from "../signals/selectedMode";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button } from "@storyteller/ui-button";
import { faCheck, faPersonRunning } from "@fortawesome/pro-solid-svg-icons";
declare global {
interface Window {
__mouseControls?: {
toggleFKMode: () => void;
};
}
}
interface PoseModeSelectorProps {}
export const PoseModeSelector: React.FC<PoseModeSelectorProps> = () => {
useSignals();
const handleModeChange = () => {
if (poseMode.value === "select") {
poseMode.value = "pose";
} else {
poseMode.value = "select";
}
window.__mouseControls?.toggleFKMode();
};
if (showPoseControls.value === false) {
return null;
}
return (
<>
<div
className="fixed left-1/2 top-32 flex -translate-x-1/2 transform items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Tooltip
content={"Toggle pose mode (K)"}
position={"bottom"}
delay={300}
closeOnClick={true}
>
<>
{poseMode.value === "select" ? (
<Button
icon={faPersonRunning}
onClick={handleModeChange}
className="rounded-xl shadow-xl outline-none focus-visible:outline-none"
>
Enter Pose Mode
</Button>
) : (
<Button
icon={faCheck}
onClick={handleModeChange}
className="rounded-xl outline-none focus-visible:outline-none"
>
Done
</Button>
)}
</>
</Tooltip>
</div>
</>
);
};
@@ -1,43 +0,0 @@
import { Signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
export const PrecisionSelector = ({
showSignal,
coordSignal,
valuesSignal,
selectedValueSignal
}: {
showSignal: Signal<boolean>,
coordSignal: Signal<{ x: number; y: number }>,
valuesSignal: Signal<number[]>
selectedValueSignal: Signal<number>
}) => {
useSignals();
const handleMouseLeave = () => {
showSignal.value = false;
}
const handleMouseEnterItem = (scale: number) => {
selectedValueSignal.value = scale;
console.log("selectedValueSignal", selectedValueSignal.value);
}
return (
<div
className="fixed z-50 bg-red-600 -translate-x-1/2 -translate-y-1/2 flex-col gap-[1px] shadow-md bg-ui-divider border-ui-divider border-2 rounded-md overflow-clip"
style={{
top: coordSignal.value.y,
left: coordSignal.value.x,
display: showSignal.value ? "flex" : "none"
}}
onMouseLeave={handleMouseLeave}
>
{valuesSignal.value.map((scale, index) => (
<span onMouseEnter={() => handleMouseEnterItem(scale)} key={index}
className="flex bg-ui-panel text-sm justify-center align-middle justify-items-center px-2 hover:bg-ui-controls"
>{scale}</span>
))}
</div>
)
}
@@ -1,53 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EditorStates } from "~/pages/PageEnigma/enums";
import { pageHeight, pageWidth } from "~/signals";
import { editorState, previewSrc } from "~/pages/PageEnigma/signals/engine";
import {
timelineHeight,
sidePanelWidth,
sidePanelVisible,
} from "~/pages/PageEnigma/signals";
export const PreviewFrameImage = () => {
useSignals();
if (editorState.value === EditorStates.PREVIEW) {
if (previewSrc.value === "") {
return (
<div
className="absolute inset-0"
style={{
width:
pageWidth.value -
(sidePanelVisible.value ? sidePanelWidth.value : 0) -
84,
height: pageHeight.value - timelineHeight.value - 56,
}}
>
<div className="relative flex h-full w-full flex-col items-center justify-center gap-5">
<span className="absolute h-full w-full bg-black opacity-50" />
<FontAwesomeIcon icon={faSpinnerThird} spin size="4x" />
<h3 className="z-20 text-white">Generating Preview...</h3>
</div>
</div>
);
} else {
return (
<img
alt="preview of the art style that renders over the 3d scene"
className="absolute inset-0 object-cover"
src={previewSrc.value}
style={{
width:
pageWidth.value -
(sidePanelVisible.value ? sidePanelWidth.value : 0) -
84,
height: pageHeight.value - timelineHeight.value - 56,
}}
/>
);
}
}
return null;
};
@@ -1,41 +0,0 @@
import { useCallback, useContext } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { pageHeight, pageWidth } from "~/signals";
import { editorLetterBox, timelineHeight } from "~/pages/PageEnigma/signals";
import { EngineContext, sceneContainerSignal } from "~/pages/PageEnigma/contexts/EngineContext";
import { Letterbox } from "./Letterbox";
export const SceneContainer = ({ children }: { children: React.ReactNode }) => {
useSignals();
const containerWidth = pageWidth.value;
const containerHeight = pageHeight.value - timelineHeight.value - 56;
const callbackRef = useCallback(
(node: HTMLDivElement) => {
if (node) {
sceneContainerSignal.value = node;
}
},
[],
);
return (
<div
ref={callbackRef}
id="video-scene-container"
className="relative"
style={{
width: containerWidth,
height: containerHeight,
}}
>
{children}
<Letterbox
isShowing={editorLetterBox.value}
width={containerWidth}
height={containerHeight}
/>
</div>
);
};

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