diff --git a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx index 99c32be680..3e7c09cb05 100644 --- a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx @@ -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() {

Create

-
+
{APPS.map((app) => (
; + return ; +} + +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)" }} > - + + {/* 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. */} + + +
+ ); +} + +function MobileGate() { + return ( +
+
+
+ +
+

+ Edit 3D is desktop-only +

+

+ 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. +

+ + + +
); } diff --git a/frontend/apps/artcraft-webapp/tailwind.config.js b/frontend/apps/artcraft-webapp/tailwind.config.js index f52b14cee2..cdcf30800d 100644 --- a/frontend/apps/artcraft-webapp/tailwind.config.js +++ b/frontend/apps/artcraft-webapp/tailwind.config.js @@ -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: [], diff --git a/frontend/libs/components/pagescene/src/lib/Stage3D.tsx b/frontend/libs/components/pagescene/src/lib/Stage3D.tsx index 4c30d91a95..5c17241535 100644 --- a/frontend/libs/components/pagescene/src/lib/Stage3D.tsx +++ b/frontend/libs/components/pagescene/src/lib/Stage3D.tsx @@ -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} > - + diff --git a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx index 41aea44557..d2d448059c 100644 --- a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx +++ b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx @@ -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 => 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" ? ( + + + + ) : 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 && }
-
+
@@ -346,7 +426,7 @@ export const Stage3DBody = () => { >
- +
@@ -355,8 +435,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" && (
{ />
)} -
- {!isViewOnly && } - -
+ {(showCostCalculator || showHelpMenu) && ( +
+ {showCostCalculator && !isViewOnly && ( + + )} + {showHelpMenu && } +
+ )}
diff --git a/frontend/libs/components/pagescene/src/lib/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx b/frontend/libs/components/pagescene/src/lib/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx index 375abd925e..41e22641a5 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx @@ -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"} >
-
Color
+

Color

{ // close. const uuid = editorEngine?.selected?.uuid; if (uuid && editorEngine) { - colorSessionRef.current = beginColorSession( - editorEngine, - uuid, - ); + colorSessionRef.current = beginColorSession(editorEngine, uuid); } }} onChange={(e: ChangeEvent) => { @@ -361,15 +357,15 @@ export const ControlPanelSceneObject = () => { }} />
-
-
Location
+
+

Location

{ />
-
-
Rotation
+
+

Rotation

{ />
-
+
-
Scale
+

Scale

{ +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 = () => { />
- -
| diff --git a/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/Help/Help.tsx b/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/Help/Help.tsx index 9a563c9ab8..5d66e21ccd 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/Help/Help.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/Help/Help.tsx @@ -37,20 +37,71 @@ export const KeyGroup = (props: { children: React.ReactNode }) => (
{props.children}
); +// 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; }) => (
- {`${props.button} + {props.label}
); +const MouseIcon = ({ active }: { active: "left" | "middle" | "right" }) => ( + + + {active === "left" && ( + + )} + {active === "right" && ( + + )} + + + +); + export const Plus = () =>
+
; const ShortcutsView = () => ( diff --git a/frontend/libs/components/pagescene/src/lib/comps/DragComponent/DragComponent.tsx b/frontend/libs/components/pagescene/src/lib/comps/DragComponent/DragComponent.tsx index 056197a637..cca71f7a55 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/DragComponent/DragComponent.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/DragComponent/DragComponent.tsx @@ -1,5 +1,14 @@ +import { createPortal } from "react-dom"; import { usePageSceneStore } from "../../PageSceneStore"; +// Portaled to 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(
{
{dragItem.name || dragItem.media_id}
-
+
, + document.body, ); }; diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor.ts b/frontend/libs/components/pagescene/src/lib/engine/editor.ts index 49cd04c2d1..65b2b2c320 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor.ts @@ -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; } } diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts index 3bd3c59d7b..e23a3184a2 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/ViewportController.ts @@ -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); diff --git a/frontend/libs/components/pagescene/src/lib/hooks/useFreeCam.ts b/frontend/libs/components/pagescene/src/lib/hooks/useFreeCam.ts index 41842ff78f..8147f70908 100644 --- a/frontend/libs/components/pagescene/src/lib/hooks/useFreeCam.ts +++ b/frontend/libs/components/pagescene/src/lib/hooks/useFreeCam.ts @@ -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]); }; diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx index aa81eaba97..1d5950261b 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx @@ -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(null); @@ -651,6 +658,7 @@ export const PromptBox3D = ({
+ {modelSelector} {selectedImageModel?.supportsNewAspectRatio() && (