Merge branch 'feature/webapp-pagescene' of github.com:storytold/storyteller-rust into feature/webapp-pagescene

# Conflicts:
#	frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
#	frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx
#	frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx
This commit is contained in:
Tanish Manku committed 2026-05-13 09:36:17 -07:00
commit f977500553
13 files changed
+392 -88

No files matched your search

@@ -4,6 +4,7 @@ import {
faImage,
faVideo,
faWandMagicSparkles,
faCube,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -37,6 +38,15 @@ const APPS: AppCard[] = [
iconBg: "bg-purple-500/25 border-purple-400/30",
iconColor: "text-purple-300",
},
{
label: "Edit 3D",
description: "Compose 3D scenes and render with AI cameras.",
href: "/edit-3d",
icon: faCube,
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/25 border-amber-400/30",
iconColor: "text-amber-300",
},
{
label: "Background Change",
description: "Swap or remove backgrounds with AI VFX.",
@@ -57,12 +67,12 @@ export function Home() {
<section className="mt-12">
<h2 className="text-sm font-semibold text-white/85 mb-4">Create</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
{APPS.map((app) => (
<Link
key={app.href}
to={app.href}
className="bg-ui-controls/50 group relative overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
className="bg-ui-controls/50 group relative h-full overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
>
<div
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDesktop, faHouse } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
import {
GalleryModal,
GalleryDragComponent,
} from "@storyteller/ui-gallery-modal";
import { useSession } from "../../lib/session";
import { useSidebar } from "../../components/ui/sidebar";
import { useSignupCta } from "../../components/signup-cta-modal";
@@ -23,20 +30,31 @@ import { useWebAppPageSceneAdapter } from "./web-adapter";
// On entry the sidebar auto-collapses on desktop to give the lib's
// 100vw-leaning layout the room it expects.
// Gate the editor on mobile. The lib's layout is built around a
// 100vw-leaning 3D viewport and pointer-precision controls (right-
// click drag pan, WASD fly cam, gizmo handles), none of which work
// on touch. The conditional is at the top level so that the editor's
// heavier hooks (adapter construction, ResizeObserver, engine mount
// inside Stage3D) never run on mobile.
export default function PageScene() {
const { isMobile } = useSidebar();
if (isMobile) return <MobileGate />;
return <PageSceneEditor />;
}
function PageSceneEditor() {
const { sceneToken } = useParams<{ sceneToken?: string }>();
const { user } = useSession();
const navigate = useNavigate();
const { setOpen, isMobile } = useSidebar();
const { setOpen } = useSidebar();
const { openSignupCta } = useSignupCta();
const didAutoCollapseRef = useRef(false);
useEffect(() => {
if (didAutoCollapseRef.current) return;
if (isMobile) return;
setOpen(false);
didAutoCollapseRef.current = true;
}, [isMobile, setOpen]);
}, [setOpen]);
useEffect(() => {
usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
@@ -89,7 +107,46 @@ export default function PageScene() {
className="relative h-full w-full overflow-hidden"
style={{ transform: "translateZ(0)" }}
>
<Stage3D adapter={adapter} sceneToken={sceneToken} />
<Stage3D
adapter={adapter}
sceneToken={sceneToken}
showCostCalculator={false}
showImageTo3DButton={false}
showHelpMenu={false}
modelSelectorPlacement="prompt-box"
/>
{/* Controls3D's "My Library" popup item flips the
galleryModalVisibleViewMode signal — the modal below subscribes
to that signal, and items inside it dispatch onto galleryDnd
which Stage3DBody's onImageDrop handler converts into scene
adds. Both components portal themselves out of this wrapper. */}
<GalleryModal mode="view" />
<GalleryDragComponent />
</div>
);
}
function MobileGate() {
return (
<div className="flex h-full w-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/25 text-amber-300">
<FontAwesomeIcon icon={faDesktop} className="text-xl" />
</div>
<h1 className="text-xl font-semibold text-white">
Edit 3D is desktop-only
</h1>
<p className="text-sm text-white/60 leading-relaxed">
The 3D editor needs a mouse and keyboard for camera flight, gizmo
handles, and precision selection. Open this page on a desktop or
laptop to start editing.
</p>
<Link to="/" className="mt-2">
<Button variant="primary" icon={faHouse}>
Back to home
</Button>
</Link>
</div>
</div>
);
}
@@ -40,6 +40,7 @@ module.exports = {
red: { ...colors.red, DEFAULT: "#D33242" },
blue: { ...colors.blue, DEFAULT: "#2E70FF" },
orange: colors.orange,
amber: colors.amber,
green: { ...colors.green, DEFAULT: "#308752" },
emerald: colors.emerald,
teal: colors.teal,
@@ -166,6 +167,9 @@ module.exports = {
divider: "#272727",
modal: "#1F1F1F",
},
"axis-x": "#D33242",
"axis-y": "#308752",
"axis-z": "#2E70FF",
},
},
plugins: [],
@@ -26,6 +26,23 @@ export interface Stage3DProps {
/** Called on unmount with the serialized scene JSON. The host
* decides where to put it (tab store, localStorage, nowhere). */
onSceneSerialized?: (json: string) => void;
/** Show the bottom-right "Costs" cost-calculator button. Defaults
* to true so existing hosts (Tauri) keep their current chrome;
* hosts can opt out (e.g. the webapp suppresses it). */
showCostCalculator?: boolean;
/** Show the top-bar "Create 3D model from image" magic-wand
* shortcut. Defaults to true; hosts without an Image-to-3D
* destination (e.g. the webapp) can hide it. */
showImageTo3DButton?: boolean;
/** Show the bottom-right help menu button. Defaults to true; hosts
* whose help menu wiring isn't yet plumbed (e.g. the webapp) can
* hide it. */
showHelpMenu?: boolean;
/** Where to render the model picker. `"bottom-left"` (default) keeps
* the floating ClassyModelSelector that Tauri uses; `"prompt-box"`
* hides it and renders a compact selector inside the prompt-box
* toolbar instead, matching the webapp's other prompt-box chrome. */
modelSelectorPlacement?: "bottom-left" | "prompt-box";
}
export const Stage3D = ({
@@ -33,6 +50,10 @@ export const Stage3D = ({
sceneToken,
cacheJsonString,
onSceneSerialized,
showCostCalculator = true,
showImageTo3DButton = true,
showHelpMenu = true,
modelSelectorPlacement = "bottom-left",
}: Stage3DProps) => {
// Engine's remountEngine() gate reads is3DPageMounted. With Stage3D
// mounting only when the host's tab/route puts us on screen, the
@@ -52,7 +73,12 @@ export const Stage3D = ({
cacheJsonString={cacheJsonString}
onSceneSerialized={onSceneSerialized}
>
<Stage3DBody />
<Stage3DBody
showCostCalculator={showCostCalculator}
showImageTo3DButton={showImageTo3DButton}
showHelpMenu={showHelpMenu}
modelSelectorPlacement={modelSelectorPlacement}
/>
<DragComponent />
<PrecisionSelector />
<EditorLoadingBar />
@@ -7,7 +7,13 @@
// exact same 3D editor UX. Only the platform-specific PageSceneAdapter
// implementation differs between hosts.
import React, { useContext, useEffect, useRef } from "react";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import {
GalleryItem,
onImageDrop,
@@ -16,10 +22,14 @@ import {
import {
STAGE_3D_PAGE_MODEL_LIST,
ModelPage,
defaultModelForPage,
useClassyModelSelectorStore,
useSelectedImageModel,
useSelectedProviderForModel,
ClassyModelSelector,
} from "@storyteller/ui-model-selector";
import { PopoverMenu } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import type { ImageModel } from "@storyteller/model-list";
import type { GenerationProvider } from "@storyteller/api-enums";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
@@ -59,7 +69,27 @@ import type { MediaItem } from "./models/assets";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
export const Stage3DBody = () => {
export interface Stage3DBodyProps {
/** Show the bottom-right "Costs" cost-calculator button. */
showCostCalculator?: boolean;
/** Show the top-bar "Create 3D model from image" magic-wand button. */
showImageTo3DButton?: boolean;
/** Show the bottom-right help menu button. */
showHelpMenu?: boolean;
/** Where to render the model picker. `"bottom-left"` (default) keeps
* the existing ClassyModelSelector floating in the editor corner —
* used by Tauri. `"prompt-box"` hides the corner selector and
* renders a compact popover inside the prompt-box toolbar instead,
* matching the webapp's other prompt boxes. */
modelSelectorPlacement?: "bottom-left" | "prompt-box";
}
export const Stage3DBody = ({
showCostCalculator = true,
showImageTo3DButton = true,
showHelpMenu = true,
modelSelectorPlacement = "bottom-left",
}: Stage3DBodyProps = {}) => {
const camAspect = usePageSceneStore((s) => s.cameraAspectRatio);
const outlinerShowing = usePageSceneStore((s) => s.outlinerShowing);
const editorLoader = usePageSceneStore((s) => s.editorLoader);
@@ -101,6 +131,63 @@ export const Stage3DBody = () => {
const selectedProvider: GenerationProvider | undefined =
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
// Inline (prompt-box) model selector. Built here rather than in
// PromptBox3D so the promptbox lib doesn't take a new dep on
// model-selector; the selector is just plumbed in as a ReactNode slot.
const setSelectedModel = useClassyModelSelectorStore(
(s) => s.setSelectedModel,
);
// Seed the default model on mount when we're the only model picker
// on the page. ClassyModelSelector does this itself on mount, but in
// the prompt-box placement we don't render it — so without this
// effect the store stays empty and the trigger has no icon until the
// user opens the popover and picks a model manually.
useEffect(() => {
if (modelSelectorPlacement !== "prompt-box") return;
if (selectedImageModel) return;
const models = STAGE_3D_PAGE_MODEL_LIST.map((i) => i.model).filter(
(m): m is NonNullable<typeof m> => m !== undefined,
);
const def = defaultModelForPage(models, PAGE_ID);
if (def) setSelectedModel(PAGE_ID, def);
}, [modelSelectorPlacement, selectedImageModel, setSelectedModel]);
const inlineModelItems: PopoverItem[] = useMemo(
() =>
STAGE_3D_PAGE_MODEL_LIST.map((item) => ({
...item,
selected: item.model === selectedImageModel,
})),
[selectedImageModel],
);
const handleInlineModelSelect = useCallback(
(item: PopoverItem) => {
if (item.model) setSelectedModel(PAGE_ID, item.model);
},
[setSelectedModel],
);
const selectedModelIcon = useMemo(
() =>
STAGE_3D_PAGE_MODEL_LIST.find((i) => i.model === selectedImageModel)
?.icon,
[selectedImageModel],
);
const inlineModelSelector =
modelSelectorPlacement === "prompt-box" ? (
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={inlineModelItems}
onSelect={handleInlineModelSelect}
mode="toggle"
panelTitle="Select Model"
panelClassName="min-w-[260px]"
showIconsInList
triggerIcon={selectedModelIcon}
/>
</Tooltip>
) : undefined;
const imageCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[PAGE_ID],
);
@@ -124,10 +211,7 @@ export const Stage3DBody = () => {
if (viewport.width > 2000) return scaleHeight;
return scaleHeight * 0.78;
}
if (
camAspect === CameraAspectRatio.SQUARE_1_1 &&
viewport.width < 2000
) {
if (camAspect === CameraAspectRatio.SQUARE_1_1 && viewport.width < 2000) {
return scaleHeight * 0.85;
}
return scaleHeight;
@@ -304,8 +388,7 @@ export const Stage3DBody = () => {
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state) => {
if (state.status)
console.log("Upload status:", state.status);
if (state.status) console.log("Upload status:", state.status);
},
});
}
@@ -327,10 +410,7 @@ export const Stage3DBody = () => {
{!isViewOnly && <OnboardingHelper />}
<div className="relative flex h-full w-full">
<div
id="engine-n-panels-wrapper"
className="flex h-full w-full"
>
<div id="engine-n-panels-wrapper" className="flex h-full w-full">
<div className="relative w-full overflow-hidden bg-transparent">
<SceneContainer>
<EditorCanvas />
@@ -346,7 +426,7 @@ export const Stage3DBody = () => {
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D />
<Controls3D showImageTo3DButton={showImageTo3DButton} />
<div className="flex items-start justify-end gap-2 pr-2 pt-2">
<AnonHintChip />
</div>
@@ -355,8 +435,7 @@ export const Stage3DBody = () => {
)}
<div
className="absolute bottom-0 left-0"
style={{ width: viewport.width }}
className="absolute bottom-0 left-0 right-0"
onClick={handleOverlayClick}
>
<div
@@ -406,6 +485,7 @@ export const Stage3DBody = () => {
editor.positive_prompt = prompt;
}}
snapshotCurrentFrame={editor?.snapShotOfCurrentFrame.bind(editor)}
modelSelector={inlineModelSelector}
onBeforeSubmit={() => {
const currentUserToken =
usePageSceneStore.getState().currentUserToken;
@@ -424,7 +504,7 @@ export const Stage3DBody = () => {
message={editorLoader.message}
/>
{!isViewOnly && (
{!isViewOnly && modelSelectorPlacement === "bottom-left" && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
@@ -437,10 +517,14 @@ export const Stage3DBody = () => {
/>
</div>
)}
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
{!isViewOnly && <CostCalculatorButton modelPage={PAGE_ID} />}
<HelpMenuButton />
</div>
{(showCostCalculator || showHelpMenu) && (
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
{showCostCalculator && !isViewOnly && (
<CostCalculatorButton modelPage={PAGE_ID} />
)}
{showHelpMenu && <HelpMenuButton />}
</div>
)}
</div>
</div>
</div>
@@ -25,10 +25,7 @@ import { objectMismatch } from "./utils/objectMismatch";
import { XYZ } from "../../datastructures/common";
import { useViewportSize } from "../../hooks/useViewportSize";
import { DraggablePrecisionMutator } from "./DraggablePrecisionMutator";
import {
beginColorSession,
ColorSession,
} from "../../actions/setObjectColor";
import { beginColorSession, ColorSession } from "../../actions/setObjectColor";
import {
beginTransformSession,
TransformSession,
@@ -161,7 +158,9 @@ export const ControlPanelSceneObject = () => {
setLocalRotation(engineToLocal(vectors.rotation));
setLocalScale(engineToLocal(vectors.scale));
setLocked(editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || ""));
setLocked(
editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || ""),
);
setColor(editorEngine?.selected?.userData.color);
// No cleanup function — uuid-change commit lives in the body
// above. Unmount commit is handled by the separate effect below.
@@ -327,7 +326,7 @@ export const ControlPanelSceneObject = () => {
className={"flex flex-col gap-2 overflow-y-auto"}
>
<div className="flex flex-col gap-1">
<h5>Color</h5>
<p className="font-semibold text-sm">Color</p>
<input
className="h-0 w-0 cursor-pointer opacity-0"
id={colorInputId}
@@ -341,10 +340,7 @@ export const ControlPanelSceneObject = () => {
// close.
const uuid = editorEngine?.selected?.uuid;
if (uuid && editorEngine) {
colorSessionRef.current = beginColorSession(
editorEngine,
uuid,
);
colorSessionRef.current = beginColorSession(editorEngine, uuid);
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
@@ -361,15 +357,15 @@ export const ControlPanelSceneObject = () => {
}}
/>
<Button
className="cursor-pointer p-3.5"
className="cursor-pointer p-3.5 w-full"
htmlFor={colorInputId}
style={{
backgroundColor: color,
}}
></Button>
</div>
<div className="flex flex-col gap-1">
<h5>Location</h5>
<div className="flex flex-col gap-2">
<p className="font-semibold text-sm">Location</p>
<InputVector
x={localPosition.x.toString()}
y={localPosition.y.toString()}
@@ -381,8 +377,8 @@ export const ControlPanelSceneObject = () => {
/>
</div>
<div className="flex flex-col gap-1">
<h5>Rotation</h5>
<div className="flex flex-col gap-2">
<p className="font-semibold text-sm">Rotation</p>
<InputVector
x={localRotation.x.toString()}
y={localRotation.y.toString()}
@@ -395,9 +391,9 @@ export const ControlPanelSceneObject = () => {
/>
</div>
<div className="mb-1 flex flex-col gap-1">
<div className="mb-1 flex flex-col gap-2">
<DraggablePrecisionMutator onChange={handleUniformScaleChange}>
<h5>Scale</h5>
<p className="font-semibold text-sm">Scale</p>
</DraggablePrecisionMutator>
<InputVector
x={localScale.x.toString()}
@@ -30,7 +30,15 @@ import {
} from "../../PageSceneStore";
import { AssetModal } from "../AssetMenu";
export const Controls3D = () => {
export interface Controls3DProps {
/** Show the magic-wand "Create 3D model from image" shortcut next
* to the add-asset button. */
showImageTo3DButton?: boolean;
}
export const Controls3D = ({
showImageTo3DButton = true,
}: Controls3DProps = {}) => {
useSignals();
const editor = useContext(EngineContext);
const {
@@ -250,19 +258,21 @@ export const Controls3D = () => {
/>
</Tooltip>
</div>
<Tooltip
content="Create 3D model from image"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
{showImageTo3DButton && (
<Tooltip
content="Create 3D model from image"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faMagicWandSparkles}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenCreate3dModal}
/>
</Tooltip>
)}
</div>
<span className="opacity-20">|</span>
@@ -37,20 +37,71 @@ export const KeyGroup = (props: { children: React.ReactNode }) => (
<div className="flex gap-1">{props.children}</div>
);
// Inline SVG instead of /resources/icons/mouse_*.png. The PNGs only
// ship with the Tauri host's public dir, so the webapp host showed a
// broken-image placeholder. Inlining keeps the lib self-contained and
// renders identically across both apps.
export const Mouse = (props: {
button: "left" | "middle" | "right";
label?: string;
}) => (
<div className="flex items-center gap-2.5">
<img
alt={`${props.button} mouse`}
src={`/resources/icons/mouse_${props.button === "left" ? "lmb" : props.button === "middle" ? "mmb" : "rmb"}.png`}
className="h-7 w-auto object-contain"
/>
<MouseIcon active={props.button} />
<span className="text-sm font-normal opacity-60">{props.label}</span>
</div>
);
const MouseIcon = ({ active }: { active: "left" | "middle" | "right" }) => (
<svg
viewBox="0 0 20 28"
aria-hidden
className="h-7 w-auto text-white"
>
<rect
x="1.25"
y="1.25"
width="17.5"
height="25.5"
rx="8.75"
ry="11"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.85"
/>
{active === "left" && (
<path
d="M9 1.75 V12 H1.5 V10 A8.5 8.5 0 0 1 9 1.75 Z"
fill="currentColor"
/>
)}
{active === "right" && (
<path
d="M11 1.75 V12 H18.5 V10 A8.5 8.5 0 0 0 11 1.75 Z"
fill="currentColor"
/>
)}
<rect
x="9"
y="4"
width="2"
height="5"
rx="1"
fill="currentColor"
opacity={active === "middle" ? "1" : "0.5"}
/>
<line
x1="1.5"
y1="12"
x2="18.5"
y2="12"
stroke="currentColor"
strokeWidth="0.75"
opacity="0.4"
/>
</svg>
);
export const Plus = () => <div className="text-xl font-medium">+</div>;
const ShortcutsView = () => (
@@ -1,5 +1,14 @@
import { createPortal } from "react-dom";
import { usePageSceneStore } from "../../PageSceneStore";
// Portaled to <body> so the chip stays anchored to the page (using the
// document-coord pageX/pageY values stored in dragPosition) regardless
// of any positioned/`transform`ed ancestor the host wraps Stage3D in.
// Without the portal, hosts whose Stage3D wrapper is a containing
// block (e.g. the webapp's wrapper uses `transform: translateZ(0)` to
// scope the lib's fixed overlays) push the chip away from the cursor
// by the wrapper's offset. Tauri has no such wrapper so the portal is
// a no-op for it.
export const DragComponent = () => {
const dragItem = usePageSceneStore((s) => s.dragItem);
const dragPosition = usePageSceneStore((s) => s.dragPosition);
@@ -7,15 +16,18 @@ export const DragComponent = () => {
if (!dragItem) {
return null;
}
if (typeof document === "undefined") {
return null;
}
const { currX, currY } = dragPosition;
const thumbnail = dragItem.thumbnail
? dragItem.thumbnail
: `/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
return (
return createPortal(
<div
className="absolute rounded-lg"
className="pointer-events-none absolute rounded-lg"
style={{
width: 91,
height: 114,
@@ -35,6 +47,7 @@ export const DragComponent = () => {
<div className="w-full truncate rounded-b-lg bg-ui-controls px-2 py-1 text-center text-[12px]">
{dragItem.name || dragItem.media_id}
</div>
</div>
</div>,
document.body,
);
};
@@ -164,9 +164,8 @@ class Editor {
// strictly one-way: UI toggles emit GridVisibleChangedEvent on the
// bus → bridge updates the store + this subscriber updates the
// THREE.js gridHelper. No "store→engine" subscription anywhere.
this.gridSubscription = this.bus.subscribe(
GridVisibleChangedEvent,
(e) => this.activeScene?.applyGridVisibility(e.visible),
this.gridSubscription = this.bus.subscribe(GridVisibleChangedEvent, (e) =>
this.activeScene?.applyGridVisibility(e.visible),
);
// PostProcessingPipeline must exist before Scene because Scene's
@@ -191,8 +190,7 @@ class Editor {
hideObjectPanel: () =>
this.bus.emit(new InspectorPanelChangedEvent(null)),
getCameras: () => usePageSceneStore.getState().cameras,
getSelectedCameraId: () =>
usePageSceneStore.getState().selectedCameraId,
getSelectedCameraId: () => usePageSceneStore.getState().selectedCameraId,
bus: this.bus,
});
this.selection = new SelectionBridge({
@@ -203,8 +201,7 @@ class Editor {
setObjectLocked: (uuid, locked) =>
this.utils.setObjectLocked(uuid, locked),
isObjectLocked: (uuid) => this.utils.isObjectLocked(uuid),
removeTransformControls: () =>
this.utils.removeTransformControls(false),
removeTransformControls: () => this.utils.removeTransformControls(false),
attachGizmoToCurrentSelection: () => {
this.gizmo.addToScene(this.activeScene.scene);
const selected = this.sceneManager?.selected_objects?.[0];
@@ -232,8 +229,7 @@ class Editor {
getSelectedCameraId: () =>
usePageSceneStore.getState().selectedCameraId,
fetchAsset: (url) => this.adapter.fetchAsset(url),
getMediaUrlByToken: (token) =>
this.adapter.getMediaUrlByToken(token),
getMediaUrlByToken: (token) => this.adapter.getMediaUrlByToken(token),
},
);
this.activeScene.initialize();
@@ -280,8 +276,7 @@ class Editor {
saveSceneState: (args) => this.adapter.saveScene(args),
loadSceneState: (token) => this.adapter.loadScene(token),
getCameras: () => usePageSceneStore.getState().cameras,
getSelectedCameraId: () =>
usePageSceneStore.getState().selectedCameraId,
getSelectedCameraId: () => usePageSceneStore.getState().selectedCameraId,
bus: this.bus,
});
this.viewport = new ViewportController({
@@ -291,6 +286,13 @@ class Editor {
getRenderAspectRatio: () =>
this.cameraController.getRenderDimensions().aspectRatio,
resizePostProcessing: (w, h) => this.postProcessing.resize(w, h),
renderScene: () => {
// Fire-and-forget: renderScene is async only for optional
// post-processing branches; the underlying Three.js render is
// synchronous and that's all the viewport needs to avoid a
// blank frame after setSize.
void this.renderScene();
},
});
// Action classes under engine/editor/actions/ encapsulate their own
@@ -581,7 +583,6 @@ class Editor {
document.body.appendChild(this.stats.dom);
}
// Captures the scene without the grid
public snapShotOfCurrentFrame(shouldDownload: boolean = true) {
const camera = this.cameraController.camera;
@@ -791,7 +792,8 @@ class Editor {
// Render the scene to the camera, this is called in the update.
async renderScene() {
const { render_camera, render_width, render_height } = this.cameraController;
const { render_camera, render_width, render_height } =
this.cameraController;
if (
this.postProcessing.composer != null &&
this.rawRenderer &&
@@ -811,12 +813,14 @@ class Editor {
if (this.viewport.container) {
if (
this.viewport.container.clientWidth + this.viewport.container.clientHeight !==
this.viewport.container.clientWidth +
this.viewport.container.clientHeight !==
this.viewport.lastCanvasSize
) {
this.viewport.onWindowResize();
this.viewport.lastCanvasSize =
this.viewport.container.clientWidth + this.viewport.container.clientHeight;
this.viewport.container.clientWidth +
this.viewport.container.clientHeight;
}
}
@@ -20,6 +20,12 @@ export type ViewportEngineRefs = {
getRenderer: () => THREE.WebGLRenderer | undefined;
getRenderAspectRatio: () => number;
resizePostProcessing: (width: number, height: number) => void;
// Called synchronously after a setSize cascade so the canvas gets a
// fresh draw before the next browser paint. Three.js's setSize clears
// the canvas backing buffer; without an immediate redraw the browser
// can paint an empty canvas for one frame, which shows up as flicker
// when the container is animating (e.g. sidebar collapse).
renderScene: () => void;
};
export class ViewportController {
@@ -65,13 +71,17 @@ export class ViewportController {
}
// Observed resizes from the ResizeObserver use a narrower update —
// camera + renderer.setSize + setPixelRatio only — preserved from the
// original editor.ts behaviour. Post-processing is intentionally
// skipped here, matching the pre-refactor code.
// camera + renderer.setSize only — preserved from the original
// editor.ts behaviour. Post-processing is intentionally skipped here,
// matching the pre-refactor code. setPixelRatio runs once at setup
// since DPR doesn't change for the lifetime of the page short of the
// user dragging the window across monitors with different scaling.
setupResizeObserver() {
this.containerMayReset();
if (!this.container) return;
this.engine.getRenderer()?.setPixelRatio(window.devicePixelRatio);
this.observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
@@ -81,8 +91,11 @@ export class ViewportController {
camera.updateProjectionMatrix();
}
const renderer = this.engine.getRenderer();
renderer?.setSize(width, height);
renderer?.setPixelRatio(window.devicePixelRatio);
if (!renderer) continue;
renderer.setSize(width, height);
// setSize wipes the canvas backing buffer. Redraw synchronously
// so the next browser paint isn't an empty frame.
this.engine.renderScene();
}
});
this.observer.observe(this.container);
@@ -47,6 +47,16 @@ const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
return target.isContentEditable;
};
// Hold-Shift boost matches the help-dialog "Speed Boost" shortcut.
// 3x is what the legacy MouseControls.onkeydown path used (and which
// never got wired up to a listener in the React refactor), so we
// preserve that feel here.
const BASE_MOVEMENT_SPEED = 1.15;
const BOOSTED_MOVEMENT_SPEED = BASE_MOVEMENT_SPEED * 3;
const isShiftKey = (code: string) =>
code === "ShiftLeft" || code === "ShiftRight";
export const useFreeCam = (
canvas: HTMLCanvasElement | null,
editor: Editor | null,
@@ -74,6 +84,9 @@ export const useFreeCam = (
const onKeyDown = (e: KeyboardEvent) => {
if (usePageSceneStore.getState().isPromptBoxFocused) return;
if (isEventFromEditableElement(e)) return;
if (isShiftKey(e.code)) {
state.movementSpeed = BOOSTED_MOVEMENT_SPEED;
}
const moveSlot = moveSlotForKeyCode(e.code);
if (moveSlot) state.moveKeys[moveSlot] = 1;
const rotateSlot = rotateSlotForKeyCode(e.code);
@@ -83,6 +96,9 @@ export const useFreeCam = (
const onKeyUp = (e: KeyboardEvent) => {
if (usePageSceneStore.getState().isPromptBoxFocused) return;
if (isEventFromEditableElement(e)) return;
if (isShiftKey(e.code)) {
state.movementSpeed = BASE_MOVEMENT_SPEED;
}
const moveSlot = moveSlotForKeyCode(e.code);
if (moveSlot) state.moveKeys[moveSlot] = 0;
const rotateSlot = rotateSlotForKeyCode(e.code);
@@ -138,6 +154,15 @@ export const useFreeCam = (
const onContextMenu = (e: Event) => e.preventDefault();
// Reset on blur — if the user holds Shift, switches tabs/windows,
// then releases, the keyup never reaches us and the boost would
// otherwise stay stuck on. Same goes for any held movement keys.
const onBlur = () => {
state.movementSpeed = BASE_MOVEMENT_SPEED;
state.moveKeys = emptyMoveKeys();
state.rotateKeys = emptyRotateKeys();
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointermove", onPointerMove);
@@ -145,9 +170,11 @@ export const useFreeCam = (
canvas.addEventListener("contextmenu", onContextMenu);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
state.enabled = false;
state.movementSpeed = BASE_MOVEMENT_SPEED;
state.moveKeys = emptyMoveKeys();
state.rotateKeys = emptyRotateKeys();
state.velocity.set(0, 0, 0);
@@ -159,6 +186,7 @@ export const useFreeCam = (
canvas.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
};
}, [canvas, editor]);
};
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, type ReactNode } from "react";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import {
faCamera,
@@ -74,6 +74,12 @@ interface PromptBox3DProps {
} | null)
| undefined;
credits?: number | null;
/** Optional model-picker slot rendered at the start of the toolbar
* (left of the aspect-ratio picker). Tauri leaves this unset and
* renders ClassyModelSelector outside the prompt box; the webapp
* passes a compact selector here so the chrome matches its other
* prompt boxes. */
modelSelector?: ReactNode;
// Optional pre-submit gate. Returns false to abort the generation
// (e.g. host wants to open a signup modal for anon visitors).
onBeforeSubmit?: () => boolean;
@@ -103,6 +109,7 @@ export const PromptBox3D = ({
selectedProvider,
snapshotCurrentFrame,
credits,
modelSelector,
onBeforeSubmit,
}: PromptBox3DProps) => {
//const fileInputRef = useRef<HTMLInputElement>(null);
@@ -651,6 +658,7 @@ export const PromptBox3D = ({
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{modelSelector}
{selectedImageModel?.supportsNewAspectRatio() && (
<AspectRatioPicker
model={selectedImageModel}