Scene Builder checkpoint: camera view fixes, record/capture + completion modal, save/load + undo persistence

Builds on the prior Scene Builder commit. Compile-verified (tsc -b clean for
pagescene + changed webapp files); see frontend/libs/components/pagescene/CLAUDE.md.

- Camera view: fixed record→build engine-teardown wipe (PreviewEngineCamera /
  cam-view canvas must stay mounted — hide via CSS, never unmount); Esc +
  viewport double-click exit; double-click frustum to enter; robust enter/exit
  pose; frustum pick-proxy hitbox; CameraStatusPill (Viewport vs render cam).
- Build promptbox: dedicated SceneBuilderPromptBox (MCP stub) replaced
  PromptBox3D; buildMode/toggle removed.
- Record mode: Capture (still) + Record (timeline→mp4 via mediabunny),
  RenderOverlay progress, producedArtifact cache, CompletionModal
  (preview/playback + Delete/Upload/Edit) + adapter handoff to 2D/create-video/
  video-editor (lazy, data-URL image handoff; best-effort uploads).
- Persistence: timeline + render-camera transform in scene JSON (save/load +
  unmount cache); ::CAM:: recreated on load after cameras restore.
- Undo: timeline transactional (SaveTimelineAction); camera moves via TransformAction.
This commit is contained in:
Tanish Manku committed 2026-07-06 23:26:18 -07:00
1 parent 40ec291395
commit c15d8bd1ec
23 files changed
+904 -125

No files matched your search

@@ -10,6 +10,10 @@ import { faDesktop, faHouse } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { MediaFilesApi } from "@storyteller/api";
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
import type { PageSceneArtifact } from "@storyteller/ui-pagescene";
import { applyEditOnCanvasFromImage } from "../pagedraw/edit-on-canvas";
import { uploadByKind } from "../video-editor/adapters/upload-by-kind";
import { useCreateVideoStore } from "../create-video/create-video-store";
import {
GalleryModal,
GalleryDragComponent,
@@ -239,6 +243,79 @@ function PageSceneEditor() {
navigate("/create-image");
}, [navigate]);
// Record-mode handoff: a captured still opens in the 2D editor. NO upload
// here (2D uploads at generation time) — matches the "don't upload from 3D"
// flow and avoids a network call that rejects offline. We hand PageDraw a
// self-contained data URL rather than the blob: object URL, which Safari
// invalidates across the route change (WebKitBlobResource error). Empty
// mediaToken; PageDraw's generate path uploads the base if it lacks a token.
const openImageInEditor = useCallback(
(artifact: PageSceneArtifact) => {
void (async () => {
let url = artifact.objectUrl;
try {
url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(artifact.blob);
});
} catch {
// fall back to the object URL
}
applyEditOnCanvasFromImage("", url, navigate);
})();
},
[navigate],
);
// A recorded video either seeds Create-Video as a reference (lazy — the
// page uploads it at generation) or lands in the library + opens the video
// editor. TODO(scene-builder): auto-drop onto the video-editor timeline.
const openVideoInEditor = useCallback(
(artifact: PageSceneArtifact, target: "generate" | "edit") => {
if (target === "generate") {
const file = new File([artifact.blob], artifact.fileName, {
type: artifact.mimeType,
});
useCreateVideoStore.getState().setPendingRecreate({
prompt: "",
referenceImages: [],
referenceVideos: [
{
id: crypto.randomUUID(),
url: artifact.objectUrl,
file,
mediaToken: "",
duration: 0,
},
],
referenceAudios: [],
inputMode: "reference",
});
navigate("/create-video");
return;
}
void (async () => {
// Best-effort upload to the library (so the clip is reachable from the
// video editor's asset panel). Swallow failures — offline/no-backend
// must not throw an unhandled rejection; navigate regardless.
// TODO(scene-builder): auto-drop onto the video-editor timeline.
try {
await uploadByKind({
kind: "video",
blob: artifact.blob,
fileName: artifact.fileName,
});
} catch {
// ignore — upload is best-effort until auto-import lands
}
navigate("/video-editor");
})();
},
[navigate],
);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -272,6 +349,8 @@ function PageSceneEditor() {
userToken: user?.user_token,
initialSceneToken: sceneToken,
navigateToImageTo3D,
openImageInEditor,
openVideoInEditor,
getViewportSize,
promptSignup: openSignupCta,
onRequestNewSceneSelector: openSceneSplash,
@@ -18,7 +18,11 @@ import {
UploadImageMedia,
UsersApi,
} from "@storyteller/api";
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
import type {
PageSceneAdapter,
PageSceneArtifact,
} from "@storyteller/ui-pagescene";
import { uploadByKind } from "../video-editor/adapters/upload-by-kind";
import {
getActiveEditor,
ToastTypes,
@@ -155,6 +159,14 @@ export interface WebAppPageSceneAdapterOptions {
userToken: string | undefined;
initialSceneToken: string | undefined;
navigateToImageTo3D: () => void;
// Record-mode handoff: open a produced still in the 2D editor / a produced
// video in create-video or the video editor. Built page-side (they need
// `navigate`), passed through here like navigateToImageTo3D.
openImageInEditor: (artifact: PageSceneArtifact) => void;
openVideoInEditor: (
artifact: PageSceneArtifact,
target: "generate" | "edit",
) => void;
// Wrapper size — kept in a ref so the closure sees live values without
// rebuilding the adapter on every resize.
getViewportSize: () => { width: number; height: number };
@@ -173,6 +185,8 @@ export const useWebAppPageSceneAdapter = (
userToken,
initialSceneToken,
navigateToImageTo3D,
openImageInEditor,
openVideoInEditor,
getViewportSize,
promptSignup,
onRequestNewSceneSelector,
@@ -386,6 +400,14 @@ export const useWebAppPageSceneAdapter = (
navigateToImageTo3D,
openImageInEditor,
openVideoInEditor,
// Persist a produced still/video to the media library (Upload action
// in the completion modal). Returns the media token.
uploadMedia: ({ kind, blob, fileName, title }) =>
uploadByKind({ kind, blob, fileName, title }),
promptSignup,
onRequestNewSceneSelector,
@@ -425,6 +447,8 @@ export const useWebAppPageSceneAdapter = (
userToken,
initialSceneToken,
navigateToImageTo3D,
openImageInEditor,
openVideoInEditor,
getViewportSize,
promptSignup,
onRequestNewSceneSelector,
@@ -2,6 +2,16 @@
Guidance for working in the `pagescene` library — the 3D editor (a.k.a. "pagescene" / "Stage3D").
## ⚠️ Engine-canvas mounting invariant (footgun)
`EngineProvider`'s editor-construction effect depends on three DOM nodes: `sceneContainer`,
`editorCanvas`, and **`camViewCanvas`** (owned by `PreviewEngineCamera` → `CameraViewCanvas`).
If ANY of these unmounts, the effect tears down + recreates the whole `Editor` (serializing to
cache, then reloading) — which wipes a fresh/unsaved scene. **Never conditionally unmount the
components that host these canvases** (`SceneContainer`, `EditorCanvas`, `PreviewEngineCamera`)
for mode/chrome gating — hide them with CSS (`hidden`) instead. (Regression fixed: record mode
had gated `PreviewEngineCamera` behind `!isRecord`, wiping the scene on record→build toggle.)
## Architecture (existing, do not rebuild)
Strict one-way data flow. Do not violate it.
@@ -38,6 +48,55 @@ Rule: a scene with **no animations/keyframes (static)** defaults to **Build**.
This mode does not exist in code today. It needs a new store field (e.g. `sceneMode: "build" | "record"`) and a new component rendered in `Stage3DBody`. Distinct from the existing `editorState` enum (`EDIT` | `CAMERA_VIEW`), which is a different axis — don't overload it.
### Spec revision — 3D delegates generation to 2D/Video (branch `feature/scene-builder`)
The 3D editor no longer generates. It composes; 2D (pagedraw) and the video experiences generate.
- **Build promptbox is now the scene-builder/MCP tool only** — `comps/SceneBuilderPromptBox/`
replaced `PromptBox3D` in `Stage3DBody` (the Manual/Prompted toggle + `buildMode` store field are
gone; `PromptBox3D` is no longer mounted by 3D). Update is still an MCP stub (records prompt +
history on the engine). Hosts the collapsed `TimelineBar` + Add-animation-timeline affordance.
- **Record mode = Capture (still) / Record (timeline → video)**:
- `engine/recording/TimelineRecorder.ts` — deterministic `seekTo`→`renderScene`→`CanvasSource.add`
encode via **mediabunny** (already a dep). Capture reuses `snapShotOfCurrentFrame(false)`.
- `comps/RenderOverlay/` (opaque `LoadingDots` + progress) covers the viewport while encoding.
- Output cached locally as `producedArtifact` in the store (`{kind, blob, objectUrl, ...}`) — no
upload from 3D.
- **`comps/CompletionModal/`** — preview hub: image full-preview / video playback + **Delete**
(revokes URL), **Upload** (`adapter.uploadMedia` → library token), **Edit** → 2D for images,
Create-Video / Video-editor for videos.
- **Adapter handoff (new)** in `adapter.ts`: `openImageInEditor`, `openVideoInEditor(target)`,
`uploadMedia` + `PageSceneArtifact`. Host-wired in `apps/artcraft-webapp/.../pagescene` :
image → eager-upload + `applyEditOnCanvasFromImage` (`/edit-image`); video "generate" →
`useCreateVideoStore.setPendingRecreate` reference-video (lazy upload at generate, `/create-video`);
video "edit" → upload + `/video-editor` (TODO: auto-drop on timeline).
- **Camera-view regression fixes** (Phase 0): Esc + viewport double-click exit; double-click the
frustum to enter; robust enter/exit pose handling (removed the dead `hot_items` toggle);
frustum **pick-proxy mesh** (line raycasting disabled) so selection matches the wireframe;
`CameraStatusPill` (top-left) shows Viewport vs render-camera and toggles.
Deferred: real MCP backend; pagedraw file-only (lazy) base image; video-editor timeline
auto-import; audio in recordings; `snapShotOfCurrentFrame` render-camera reconciliation.
### Persistence + undo (timeline & split camera)
- **Timeline persists in scene JSON**: `save_manager.getSceneJson` now writes `timeline:
getTimeline() ?? null` (was a `""` stub); `loadFromJson` restores via `loadTimeline` (guards
legacy `""`/null). Flows through both backend save and the unmount→cache→remount roundtrip.
Loaded objects keep their saved `object_uuid` (proxy load, `obj.uuid = json_object.object_uuid`),
so timeline tracks (keyed by uuid) resolve.
- **Render camera persists**: at save, the render-camera entry in `cameras[]` is overridden with the
**live `::CAM::` transform** (`getRenderCameraTransform` dep) so it reflects gizmo moves (the store
config otherwise goes stale). The load path strips `::CAM::` and never recreated it — fixed:
`loadFromJson` calls `recreateCameraObject()` (→ `Scene._create_camera_obj()`, now idempotent)
AFTER `CamerasReplacedEvent`, so the frustum lands at the restored transform. This also fixes the
standalone "load loses the render camera" bug.
- **Undo/redo**: timeline is **transactional** — `Save` is one undo step (`SaveTimelineAction`;
`loadTimeline` re-seeks + emits on undo/redo); `Cancel` reverts to last saved (not history).
Render-camera gizmo moves were already undoable (`TransformAction` on `::CAM::`, no special-casing);
because save reads the live `cam_obj`, a save after an undo persists the undone position.
Per-edit timeline undo remains deferred.
### Implementation status (branch `feature/scene-builder`)
- **DONE — Build/Record pill**: `comps/SceneModePill/` (reuses `@storyteller/ui-tab-selector`), mounted above `Controls3D` in `Stage3DBody`. Store: `sceneMode` (default `build`) + `setSceneMode`.
@@ -7,6 +7,7 @@ export type { Stage3DProps } from "./lib/Stage3D";
// Adapter (platform abstraction)
export type {
PageSceneAdapter,
PageSceneArtifact,
PageSceneGenerateRequest,
PageSceneSavePayload,
ListMediaFilesQuery,
@@ -20,6 +20,25 @@ export type {
} from "./engine/timeline/types";
import type { TimelineTrack } from "./engine/timeline/types";
// A still (Capture) or video (Record) produced by Record mode, cached
// locally (object URL) before any upload. Powers the completion modal's
// preview/playback and the 2D/video handoff.
export interface ProducedArtifact {
kind: "image" | "video";
blob: Blob;
objectUrl: string;
fileName: string;
mimeType: string;
aspectRatio: CameraAspectRatio;
}
// Progress of an in-flight Capture/Record so the RenderOverlay can cover the
// viewport (freeing GPU) and show progress while frames encode.
export interface RecordingProgress {
phase: "capturing" | "encoding";
pct: number; // 0..1
}
// Scene metadata — what host tracks in its `signalScene`. Mirrored
// into the store so ControlsTopButtons (lib-resident) can read it
// reactively. Host calls `setSceneMeta` whenever its signal changes.
@@ -87,7 +106,6 @@ export interface SelectedSceneObject {
}
export type SceneMode = "build" | "record";
export type BuildMode = "manual" | "prompted";
export type TransformMode = "move" | "rotate" | "scale";
export type TransformSpace = "world" | "local";
export type PoseMode = "select" | "pose";
@@ -144,7 +162,6 @@ interface PageSceneState {
// editor mode
sceneMode: SceneMode;
buildMode: BuildMode;
editorState: EditorStates;
transformMode: TransformMode;
transformSpace: TransformSpace;
@@ -165,6 +182,10 @@ interface PageSceneState {
timelineTracks: TimelineTrack[];
timelineSelectedKeyframeId: string | null;
// record output
producedArtifact: ProducedArtifact | null;
recordingProgress: RecordingProgress | null;
// layout / panels
assetModalVisible: boolean;
assetModalVisibleDuringDrag: boolean;
@@ -251,7 +272,6 @@ interface PageSceneState {
// editor mode
setSceneMode: (mode: SceneMode) => void;
setBuildMode: (mode: BuildMode) => void;
setEditorState: (state: EditorStates) => void;
setTransformMode: (mode: TransformMode) => void;
setTransformSpace: (space: TransformSpace) => void;
@@ -266,6 +286,9 @@ interface PageSceneState {
setTimelineDuration: (duration: number) => void;
setTimelineTracks: (tracks: TimelineTrack[]) => void;
setTimelineSelectedKeyframe: (id: string | null) => void;
setProducedArtifact: (artifact: ProducedArtifact | null) => void;
clearProducedArtifact: () => void;
setRecordingProgress: (progress: RecordingProgress | null) => void;
toggleStats: () => void;
setIgnoreKeyDelete: (ignore: boolean) => void;
disableHotkeyInput: (level: DomLevels) => void;
@@ -344,7 +367,6 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
editorState: EditorStates.EDIT,
sceneMode: "build",
buildMode: "manual",
transformMode: "move",
transformSpace: "world",
selectedMode: "move",
@@ -358,6 +380,8 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
timelineDuration: 10,
timelineTracks: [],
timelineSelectedKeyframeId: null,
producedArtifact: null,
recordingProgress: null,
ignoreKeyDelete: false,
hotkeyStatus: { disabled: false, disabledBy: DomLevels.NONE },
isPromptBoxFocused: false,
@@ -447,7 +471,6 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
// editor mode actions
setSceneMode: (mode) => set({ sceneMode: mode }),
setBuildMode: (mode) => set({ buildMode: mode }),
setEditorState: (state) => set({ editorState: state }),
setTransformMode: (mode) => set({ transformMode: mode }),
setTransformSpace: (space) => set({ transformSpace: space }),
@@ -462,6 +485,13 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setTimelineDuration: (duration) => set({ timelineDuration: duration }),
setTimelineTracks: (tracks) => set({ timelineTracks: tracks }),
setTimelineSelectedKeyframe: (id) => set({ timelineSelectedKeyframeId: id }),
setProducedArtifact: (artifact) => set({ producedArtifact: artifact }),
clearProducedArtifact: () =>
set((s) => {
if (s.producedArtifact) URL.revokeObjectURL(s.producedArtifact.objectUrl);
return { producedArtifact: null };
}),
setRecordingProgress: (progress) => set({ recordingProgress: progress }),
toggleStats: () => set((s) => ({ statsVisible: !s.statsVisible })),
setIgnoreKeyDelete: (ignore) => set({ ignoreKeyDelete: ignore }),
disableHotkeyInput: (level) => {
@@ -38,7 +38,7 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import { PromptBox3D, commonToCameraAspect } from "@storyteller/ui-promptbox";
import { commonToCameraAspect } from "@storyteller/ui-promptbox";
import type { PopoverItem } from "@storyteller/ui-popover";
import { v4 as uuidv4 } from "uuid";
@@ -57,6 +57,10 @@ import { Outliner } from "./comps/Outliner";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import { ExitCameraView } from "./comps/ExitCameraView";
import { RecordControls } from "./comps/RecordControls";
import { CameraStatusPill } from "./comps/CameraStatusPill";
import { SceneBuilderPromptBox } from "./comps/SceneBuilderPromptBox";
import { RenderOverlay } from "./comps/RenderOverlay";
import { CompletionModal } from "./comps/CompletionModal";
import { PreviewBox } from "./comps/PreviewBox";
import { PreviewEngineCamera } from "./comps/PreviewEngineCamera";
import { SceneContainer } from "./comps/SceneContainer";
@@ -130,8 +134,6 @@ export const Stage3DBody = ({
(s) => s.setIsPromptBoxFocused,
);
const gridVisible = usePageSceneStore((s) => s.gridVisible);
const buildMode = usePageSceneStore((s) => s.buildMode);
const setBuildMode = usePageSceneStore((s) => s.setBuildMode);
const timelineExists = usePageSceneStore((s) => s.timelineExists);
const timelineExpanded = usePageSceneStore((s) => s.timelineExpanded);
const setTimelineExpanded = usePageSceneStore((s) => s.setTimelineExpanded);
@@ -468,6 +470,8 @@ export const Stage3DBody = ({
<FocalLengthDisplay />
<PoseModeSelector />
<ExitCameraView />
<RenderOverlay />
<CompletionModal />
<div
className="absolute left-0 top-0 w-full"
@@ -476,6 +480,11 @@ export const Stage3DBody = ({
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-2">
{!isRecord && <ControlsTopButtons />}
{!isRecord && (
<div className="pl-3">
<CameraStatusPill />
</div>
)}
{!isRecord && topBarStartSlot && (
<div className="pl-3">{topBarStartSlot}</div>
)}
@@ -497,15 +506,19 @@ export const Stage3DBody = ({
className="absolute bottom-0 left-0 right-0"
onClick={handleOverlayClick}
>
{!isRecord && (
<div
className="absolute bottom-20 mb-4 ml-4 flex origin-bottom-left flex-col gap-2"
style={{ transform: `scale(${getScale()})` }}
>
<Outliner />
<PreviewEngineCamera />
</div>
)}
{/* Always mounted: PreviewEngineCamera owns the cam-view canvas
that EngineProvider depends on — unmounting it in record mode
tears down + recreates the whole engine (wiping the scene).
Hide with CSS in record instead. */}
<div
className={`absolute bottom-20 mb-4 ml-4 flex origin-bottom-left flex-col gap-2${
isRecord ? " hidden" : ""
}`}
style={{ transform: `scale(${getScale()})` }}
>
<Outliner />
<PreviewEngineCamera />
</div>
{!isRecord && <ControlPanelSceneObject />}
</div>
@@ -516,68 +529,7 @@ export const Stage3DBody = ({
{isRecord && <RecordControls />}
{!timelineExpanded && !isRecord && (
<PromptBox3D
cameras={cameras}
cameraAspectRatio={camAspect}
disableHotkeyInput={disableHotkeyInput}
enableHotkeyInput={enableHotkeyInput}
gridVisibility={gridVisible}
setGridVisibility={(visible: boolean) =>
editor?.bus.emit(new GridVisibleChangedEvent(visible))
}
selectedCameraId={selectedCameraId}
deleteCamera={deleteCamera}
focalLengthDragging={focalLengthDragging}
setFocalLengthDragging={setFocalLengthDragging}
isPromptBoxFocused={isPromptBoxFocused}
setIsPromptBoxFocused={setIsPromptBoxFocused}
uploadImage={
editor
? (((arg: Parameters<typeof editor.adapter.uploadImage>[0]) =>
editor.adapter.uploadImage(arg)) as never)
: undefined
}
handleCameraSelect={handleCameraSelect}
handleAddCamera={handleAddCamera}
handleCameraNameChange={handleCameraNameChange}
handleCameraFocalLengthChange={handleCameraFocalLengthChange}
onAspectRatioSelect={onAspectRatioSelect}
selectedImageModel={selectedImageModel}
selectedProvider={selectedProvider}
credits={imageCredits}
setEnginePrompt={(prompt) => {
if (!editor) return;
editor.positive_prompt = prompt;
}}
snapshotCurrentFrame={editor?.snapShotOfCurrentFrame.bind(editor)}
modelSelector={inlineModelSelector}
aboveStackSlot={
<>
<OnboardingHelper />
{timelineExists && !timelineExpanded && <TimelineBar />}
{promptboxAboveStackSlot}
</>
}
showAddTimelineButton={!timelineExists}
onBeforeSubmit={() => {
const currentUserToken =
usePageSceneStore.getState().currentUserToken;
if (!currentUserToken && editor?.adapter.promptSignup) {
editor.adapter.promptSignup("generate");
return false;
}
return true;
}}
buildMode={buildMode}
onBuildModeChange={setBuildMode}
onAddTimeline={() => {
if (!editor) return;
createTimeline(editor);
setTimelineExpanded(true);
}}
/>
)}
{!timelineExpanded && !isRecord && <SceneBuilderPromptBox />}
<LoadingDots
className="absolute left-0 top-0 z-50"
@@ -75,6 +75,16 @@ export interface PageSceneSavePayload {
// ─── Adapter ───────────────────────────────────────────────────────────
// A still (Capture) or video (Record) produced by Record mode, cached
// locally (object URL) — handed to the 2D/video editor or uploaded on demand.
export interface PageSceneArtifact {
kind: "image" | "video";
blob: Blob;
objectUrl: string;
fileName: string;
mimeType: string;
}
export interface PageSceneAdapter {
// Generation enqueue. Same shape as PageDrawAdapter.enqueueEditImage.
enqueueGeneration(
@@ -215,6 +225,26 @@ export interface PageSceneAdapter {
// implements via useTabStore.setActiveTab; web host via router push.
navigateToImageTo3D(): void;
// Record-mode handoff: hand a produced still/video to another editor to
// prompt/edit + generate there. The lib caches the artifact locally (object
// URL) and does NOT upload — the destination uploads at generation time.
// Web host: seed the destination store + router push. Tauri host may leave
// these undefined (feature is web-first).
openImageInEditor?(artifact: PageSceneArtifact): void;
openVideoInEditor?(
artifact: PageSceneArtifact,
target: "generate" | "edit",
): void;
// Explicitly persist a produced artifact to the user's media library and
// return its media token. Backs the completion modal's Upload action.
uploadMedia?(args: {
kind: "image" | "video";
blob: Blob;
fileName: string;
title?: string;
}): Promise<string>;
// Auth/logout — the SettingsModal in Controls3D needs a logout
// callback. Tauri host: setLogoutStates. Web host: web auth flow.
performLogout(): void;
@@ -0,0 +1,50 @@
import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faVideo, faUpDownLeftRight } from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "../../contexts/EngineContext";
import { toggleCameraView } from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore";
import { EditorStates } from "../../enums";
// Small status pill (top-left) showing which camera the viewport is driving:
// the free "Viewport" camera vs looking through a render camera. Clicking it
// toggles camera view. Hidden in record mode (that mode is self-evident).
export const CameraStatusPill = () => {
const editor = useContext(EngineContext);
const editorState = usePageSceneStore((s) => s.editorState);
const sceneMode = usePageSceneStore((s) => s.sceneMode);
const cameras = usePageSceneStore((s) => s.cameras);
if (sceneMode === "record") return null;
const inCameraView = editorState === EditorStates.CAMERA_VIEW;
const renderCameraLabel =
cameras.find((c) => c.id !== "main")?.label ?? "Camera";
return (
<Tooltip
content={inCameraView ? "Looking through render camera" : "Free viewport camera"}
position="bottom"
delay={300}
>
<button
type="button"
onClick={() => editor && toggleCameraView(editor)}
className={
inCameraView
? "flex items-center gap-2 rounded-full bg-brand-primary px-3 py-1.5 text-xs font-medium text-white shadow-xl transition-all duration-200"
: "glass glass-no-hover flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium text-base-fg/70 shadow-xl transition-all duration-200 hover:text-base-fg"
}
>
<FontAwesomeIcon
icon={inCameraView ? faVideo : faUpDownLeftRight}
className="h-3 w-3"
/>
{inCameraView ? renderCameraLabel : "Viewport"}
</button>
</Tooltip>
);
};
export default CameraStatusPill;
@@ -0,0 +1 @@
export { CameraStatusPill } from "./CameraStatusPill";
@@ -0,0 +1,164 @@
import { useContext, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faTrash,
faCloudArrowUp,
faPenToSquare,
faWandMagicSparkles,
faFilm,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "../../contexts/EngineContext";
import { usePageSceneStore } from "../../PageSceneStore";
// Preview hub shown after Capture/Record. Previews the still (image) or plays
// the clip (video), then routes it: Delete (discard), Upload (persist to
// library), or Edit (hand off to 2D / video to prompt + generate). The
// artifact is cached locally (object URL) — Edit keeps it alive for the
// destination; only Delete revokes it.
export const CompletionModal = () => {
const editor = useContext(EngineContext);
const artifact = usePageSceneStore((s) => s.producedArtifact);
const setProducedArtifact = usePageSceneStore((s) => s.setProducedArtifact);
const clearProducedArtifact = usePageSceneStore(
(s) => s.clearProducedArtifact,
);
const [uploading, setUploading] = useState(false);
const [uploaded, setUploaded] = useState(false);
if (!artifact) return null;
const isVideo = artifact.kind === "video";
const handleDelete = () => {
clearProducedArtifact(); // revokes the object URL
};
// Detach from the store WITHOUT revoking — the destination editor keeps
// using the object URL / blob.
const handoffAndClose = (invoke: () => void) => {
invoke();
setProducedArtifact(null);
};
const handleUpload = async () => {
if (!editor?.adapter.uploadMedia || uploading) return;
setUploading(true);
try {
await editor.adapter.uploadMedia({
kind: artifact.kind,
blob: artifact.blob,
fileName: artifact.fileName,
});
setUploaded(true);
} catch {
// Upload failed (e.g. offline / no backend). Leave un-uploaded so the
// user can retry; don't throw an unhandled rejection.
} finally {
setUploading(false);
}
};
return (
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm">
<div className="glass glass-no-hover flex w-full max-w-xl flex-col gap-4 rounded-2xl p-5 text-white shadow-2xl">
<div className="text-sm font-semibold text-base-fg/90">
{isVideo ? "Recording complete" : "Capture complete"}
</div>
{/* preview */}
<div className="flex max-h-[52vh] items-center justify-center overflow-hidden rounded-xl bg-black/30">
{isVideo ? (
<video
src={artifact.objectUrl}
controls
autoPlay
loop
className="max-h-[52vh] w-full object-contain"
/>
) : (
<img
src={artifact.objectUrl}
alt="Captured frame"
className="max-h-[52vh] w-full object-contain"
/>
)}
</div>
{/* actions */}
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={handleDelete}
className="flex h-9 items-center gap-2 rounded-full px-3 text-sm text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
>
<FontAwesomeIcon icon={faTrash} className="h-3.5 w-3.5" />
Delete
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleUpload}
disabled={uploading || uploaded}
className="flex h-9 items-center gap-2 rounded-full border border-ui-controls-border bg-ui-controls/60 px-4 text-sm text-base-fg backdrop-blur-lg transition-colors hover:bg-ui-controls/90 disabled:opacity-60"
>
<FontAwesomeIcon
icon={uploaded ? faCheck : faCloudArrowUp}
className="h-3.5 w-3.5"
/>
{uploaded ? "Saved" : uploading ? "Saving…" : "Upload"}
</button>
{isVideo ? (
<>
<button
type="button"
onClick={() =>
handoffAndClose(() =>
editor?.adapter.openVideoInEditor?.(artifact, "edit"),
)
}
className="flex h-9 items-center gap-2 rounded-full border border-ui-controls-border bg-ui-controls/60 px-4 text-sm text-base-fg transition-colors hover:bg-ui-controls/90"
>
<FontAwesomeIcon icon={faFilm} className="h-3.5 w-3.5" />
Video editor
</button>
<button
type="button"
onClick={() =>
handoffAndClose(() =>
editor?.adapter.openVideoInEditor?.(artifact, "generate"),
)
}
className="flex h-9 items-center gap-2 rounded-full bg-brand-primary px-4 text-sm font-medium text-white transition-transform hover:scale-[1.03]"
>
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="h-3.5 w-3.5"
/>
Prompt to video
</button>
</>
) : (
<button
type="button"
onClick={() =>
handoffAndClose(() =>
editor?.adapter.openImageInEditor?.(artifact),
)
}
className="flex h-9 items-center gap-2 rounded-full bg-brand-primary px-4 text-sm font-medium text-white transition-transform hover:scale-[1.03]"
>
<FontAwesomeIcon icon={faPenToSquare} className="h-3.5 w-3.5" />
Edit in 2D
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default CompletionModal;
@@ -0,0 +1 @@
export { CompletionModal } from "./CompletionModal";
@@ -1,27 +1,67 @@
import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCamera, faCircle } from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "../../contexts/EngineContext";
import { usePageSceneStore } from "../../PageSceneStore";
import { recordTimeline } from "../../engine/recording/TimelineRecorder";
import { TimelineBar } from "../Timeline";
// Bottom-center controls for Record mode: a read-only playback bar (only
// when a timeline exists) plus Capture (still image) and Record (video).
// Bottom-center controls for Record mode: a read-only playback bar (only when
// a timeline exists) plus Capture (still image) and Record (timeline → video).
// Both produce a locally-cached artifact and open the completion modal.
export const RecordControls = () => {
const editor = useContext(EngineContext);
const timelineExists = usePageSceneStore((s) => s.timelineExists);
const aspectRatio = usePageSceneStore((s) => s.cameraAspectRatio);
const recordingProgress = usePageSceneStore((s) => s.recordingProgress);
const setProducedArtifact = usePageSceneStore((s) => s.setProducedArtifact);
const setRecordingProgress = usePageSceneStore((s) => s.setRecordingProgress);
// Capture the current framed shot as a still image (downloads it). In
// record mode the viewport looks through the render camera, so this
// captures the composed frame.
const busy = recordingProgress !== null;
// Still capture of the composed (render-camera) frame.
const handleCapture = () => {
editor?.snapShotOfCurrentFrame(true);
if (!editor || busy) return;
setRecordingProgress({ phase: "capturing", pct: 0 });
// Defer a tick so the overlay paints before the (sync) snapshot work.
requestAnimationFrame(() => {
const snap = editor.snapShotOfCurrentFrame(false);
if (snap) {
setProducedArtifact({
kind: "image",
blob: snap.file,
objectUrl: URL.createObjectURL(snap.file),
fileName: snap.file.name,
mimeType: "image/png",
aspectRatio,
});
}
setRecordingProgress(null);
});
};
// TODO(scene-builder): wire to the video render pipeline. No video
// encoder exists in pagescene yet, so this is a stub for now.
const handleRecord = () => {
// intentionally a no-op stub for dev
// Encode the whole timeline to a video.
const handleRecord = async () => {
if (!editor || busy || !timelineExists) return;
setRecordingProgress({ phase: "encoding", pct: 0 });
try {
const result = await recordTimeline(editor, {
onProgress: (pct) => setRecordingProgress({ phase: "encoding", pct }),
});
if (result) {
setProducedArtifact({
kind: "video",
blob: result.blob,
objectUrl: URL.createObjectURL(result.blob),
fileName: result.fileName,
mimeType: result.mimeType,
aspectRatio,
});
}
} finally {
setRecordingProgress(null);
}
};
return (
@@ -35,19 +75,31 @@ export const RecordControls = () => {
<button
type="button"
onClick={handleCapture}
className="glass glass-no-hover flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium text-base-fg shadow-xl hover:bg-white/10"
disabled={busy}
className="glass glass-no-hover flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium text-base-fg shadow-xl hover:bg-white/10 disabled:opacity-50"
>
<FontAwesomeIcon icon={faCamera} className="h-3.5 w-3.5" />
Capture
</button>
<button
type="button"
onClick={handleRecord}
className="flex items-center gap-2 rounded-full bg-red px-5 py-2.5 text-sm font-semibold text-white shadow-xl transition-transform hover:scale-[1.03]"
<Tooltip
content={
timelineExists
? "Render the timeline to video"
: "Add an animation timeline first"
}
position="top"
delay={200}
>
<FontAwesomeIcon icon={faCircle} className="h-3 w-3" />
Record
</button>
<button
type="button"
onClick={handleRecord}
disabled={busy || !timelineExists}
className="flex items-center gap-2 rounded-full bg-red px-5 py-2.5 text-sm font-semibold text-white shadow-xl transition-transform hover:scale-[1.03] disabled:opacity-50 disabled:hover:scale-100"
>
<FontAwesomeIcon icon={faCircle} className="h-3 w-3" />
Record
</button>
</Tooltip>
</div>
</div>
);
@@ -0,0 +1,24 @@
import { LoadingDots } from "@storyteller/ui-loading";
import { usePageSceneStore } from "../../PageSceneStore";
// Opaque full-viewport cover shown while a Capture/Record is in flight. It
// hides the 3D scene (freeing GPU during frame encoding) and shows progress.
export const RenderOverlay = () => {
const progress = usePageSceneStore((s) => s.recordingProgress);
const message =
progress?.phase === "encoding"
? `Rendering video… ${Math.round((progress?.pct ?? 0) * 100)}%`
: "Capturing frame…";
return (
<LoadingDots
className="absolute left-0 top-0 z-[60]"
isShowing={progress !== null}
type="bricks"
message={message}
/>
);
};
export default RenderOverlay;
@@ -0,0 +1 @@
export { RenderOverlay } from "./RenderOverlay";
@@ -0,0 +1,127 @@
import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlus,
faClockRotateLeft,
faWandMagicSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { usePrompt3DStore } from "@storyteller/ui-promptbox";
import { EngineContext } from "../../contexts/EngineContext";
import { createTimeline } from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore";
import { TimelineBar } from "../Timeline";
// The Build-mode promptbox. This is the (upcoming) MCP scene-builder tool:
// describe a change and Update applies it to the scene via the descriptor
// backend. Update is stubbed until that backend lands — it records the
// prompt on the engine + in history so the flow is exercisable. Also hosts
// the collapsed timeline bar (when a timeline exists) and the
// "Add animation timeline" affordance (when the scene is still static).
export const SceneBuilderPromptBox = () => {
const editor = useContext(EngineContext);
const prompt = usePrompt3DStore((s) => s.prompt);
const setPrompt = usePrompt3DStore((s) => s.setPrompt);
const promptHistory = usePrompt3DStore((s) => s.promptHistory);
const pushPromptHistory = usePrompt3DStore((s) => s.pushPromptHistory);
const timelineExists = usePageSceneStore((s) => s.timelineExists);
const timelineExpanded = usePageSceneStore((s) => s.timelineExpanded);
const setTimelineExpanded = usePageSceneStore((s) => s.setTimelineExpanded);
const handleUpdate = () => {
const trimmed = prompt.trim();
if (!trimmed || !editor) return;
// TODO(scene-builder): send { sceneDescriptor, prompt } to the MCP
// backend and apply the returned descriptor. Stubbed for now.
editor.positive_prompt = trimmed;
pushPromptHistory(trimmed);
};
const handleAddTimeline = () => {
if (!editor) return;
createTimeline(editor);
setTimelineExpanded(true);
};
return (
<div className="absolute bottom-4 left-1/2 flex w-[90vw] max-w-3xl -translate-x-1/2 flex-col gap-3">
{timelineExists && !timelineExpanded && <TimelineBar />}
<div
className="glass relative w-full rounded-2xl p-4 text-white shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-2">
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="mt-1.5 h-4 w-4 shrink-0 text-base-fg/50"
/>
<textarea
rows={1}
placeholder="Describe a change to the scene…"
className="promptbox-scrollbar text-md max-h-[8em] min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pt-1 text-base-fg placeholder-base-fg/50 focus:outline-none"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
{!timelineExists ? (
<Button
variant="secondary"
icon={faPlus}
className="flex h-9 items-center border border-ui-controls-border bg-ui-controls/60 px-3 text-sm text-base-fg backdrop-blur-lg hover:bg-ui-controls/90"
onClick={handleAddTimeline}
>
Add animation timeline
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Tooltip
content="Prompt history"
position="top"
className="z-50"
delay={200}
closeOnClick={true}
>
<PopoverMenu
mode="toggle"
panelTitle="Recent prompts"
triggerIcon={
<FontAwesomeIcon icon={faClockRotateLeft} className="h-4 w-4" />
}
items={
promptHistory.length
? promptHistory.map((p) => ({ label: p, selected: false }))
: [{ label: "No prompts yet", selected: false, disabled: true }]
}
onSelect={(item) => {
if (item.disabled) return;
setPrompt(item.label);
}}
/>
</Tooltip>
<Button
variant="primary"
className="flex items-center border-none bg-brand-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleUpdate}
disabled={!prompt.trim()}
>
Update
</Button>
</div>
</div>
</div>
</div>
);
};
export default SceneBuilderPromptBox;
@@ -0,0 +1 @@
export { SceneBuilderPromptBox } from "./SceneBuilderPromptBox";
@@ -299,6 +299,18 @@ class Editor {
loadSceneState: (token) => this.adapter.loadScene(token),
getCameras: () => usePageSceneStore.getState().cameras,
getSelectedCameraId: () => usePageSceneStore.getState().selectedCameraId,
getTimeline: () => this.timelineController.getTimeline(),
loadTimeline: (timeline) =>
this.timelineController.loadTimeline(timeline),
getRenderCameraTransform: () => {
const cam = this.cameraController.cam_obj;
if (!cam) return null;
return {
position: { x: cam.position.x, y: cam.position.y, z: cam.position.z },
rotation: { x: cam.rotation.x, y: cam.rotation.y, z: cam.rotation.z },
};
},
recreateCameraObject: () => this.activeScene._create_camera_obj(),
bus: this.bus,
});
this.viewport = new ViewportController({
@@ -94,16 +94,20 @@ export class CameraController {
// Look through the render camera. No-op if already in camera view.
enterCameraView() {
if (this.camera_person_mode || !this.cam_obj) return;
// Require a live viewport camera: we MUST capture its pose before
// flipping state, or exit would restore the default (0,0,0) pose and
// leave the viewport pointed at empty space (looks like "everything
// disappeared").
if (this.camera_person_mode || !this.cam_obj || !this.camera) return;
this.last_cam_pos.copy(this.camera.position);
this.last_cam_rot.copy(this.camera.rotation);
this.camera_person_mode = true;
if (this.freeCamState) this.freeCamState.velocity.set(0, 0, 0);
if (this.camera) {
this.last_cam_pos.copy(this.camera.position);
this.last_cam_rot.copy(this.camera.rotation);
this.camera.position.copy(this.cam_obj.position);
this.camera.rotation.copy(this.cam_obj.rotation);
}
this.camera.position.copy(this.cam_obj.position);
this.camera.rotation.copy(this.cam_obj.rotation);
if (this.lockControls) {
this.deps.getThreeScene().add(this.lockControls.getObject());
@@ -114,11 +118,6 @@ export class CameraController {
this.deps.setSelected(this.cam_obj);
this.deps.setEditorState(EditorStates.CAMERA_VIEW);
const hot = this.deps.getHotItems();
hot?.forEach((element) => {
element.visible = false;
});
// Workaround: in camera mode, a right-click should pan rather than
// open the browser context menu. Defer until the letterbox settles.
setTimeout(
@@ -141,17 +140,13 @@ export class CameraController {
if (this.camera) {
this.camera.position.copy(this.last_cam_pos);
this.camera.rotation.copy(this.last_cam_rot);
this.camera.updateProjectionMatrix();
}
if (this.lockControls) {
this.deps.getThreeScene().remove(this.lockControls.getObject());
}
this.cam_obj.scale.set(1, 1, 1);
const hot = this.deps.getHotItems();
hot?.forEach((element) => {
element.visible = true;
});
this.deps.hideObjectPanel();
this.deps.setEditorState(EditorStates.EDIT);
}
@@ -52,6 +52,11 @@ const deleteSelected = (editor: Editor) => {
};
const onEscape = (editor: Editor) => {
// Escape leaves camera view first (before touching pose/selection).
if (editor.cameraController.getCameraPersonMode()) {
editor.cameraController.exitCameraView();
return;
}
const poseMode: PoseMode = editor.getPoseMode();
if (poseMode === "pose") {
editor.mouse_controls?.toggleFKMode();
@@ -0,0 +1,85 @@
// Deterministic timeline → video encoder. Steps the timeline frame-by-frame
// (seekTo, render, grab) and encodes with mediabunny (WebCodecs) — the same
// pattern as video-editor's SceneExporter. Avoids realtime MediaRecorder
// (whose webm duration header is unreliable).
import {
Output,
Mp4OutputFormat,
WebMOutputFormat,
BufferTarget,
CanvasSource,
QUALITY_HIGH,
} from "mediabunny";
import type Editor from "../editor";
export type RecordFormat = "mp4" | "webm";
export interface RecordResult {
blob: Blob;
mimeType: string;
fileName: string;
}
export interface RecordOptions {
format?: RecordFormat;
onProgress?: (pct: number) => void;
// Mutable flag the caller can flip to abort mid-encode.
signal?: { cancelled: boolean };
}
export async function recordTimeline(
editor: Editor,
opts: RecordOptions = {},
): Promise<RecordResult | null> {
const format = opts.format ?? "mp4";
const timeline = editor.timelineController.getTimeline();
const canvas = editor.renderer?.domElement;
if (!timeline || !canvas) return null;
const fps = timeline.fps || 30;
const frameCount = Math.max(1, Math.round(timeline.duration * fps));
// Freeze the realtime loop so our seek+render stepping isn't fought by the
// per-frame tick, then always restore it.
editor.stopRenderLoop();
try {
const output = new Output({
format:
format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(),
target: new BufferTarget(),
});
const videoSource = new CanvasSource(canvas, {
codec: format === "webm" ? "vp9" : "avc",
bitrate: QUALITY_HIGH,
});
output.addVideoTrack(videoSource, { frameRate: fps });
await output.start();
for (let i = 0; i < frameCount; i++) {
if (opts.signal?.cancelled) {
await output.cancel();
return null;
}
editor.timelineController.seekTo(i / fps);
await editor.renderScene();
await videoSource.add(i / fps, 1 / fps);
opts.onProgress?.(i / frameCount);
}
videoSource.close();
await output.finalize();
opts.onProgress?.(1);
const buffer = output.target.buffer;
if (!buffer) return null;
const mimeType = format === "webm" ? "video/webm" : "video/mp4";
return {
blob: new Blob([buffer], { type: mimeType }),
mimeType,
fileName: `scene-recording.${format}`,
};
} finally {
editor.startRenderLoop();
}
}
@@ -6,6 +6,7 @@ import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
import { CameraAspectRatio } from "../enums";
import type Scene from "./scene";
import type { EngineEventBus } from "./events/EngineEventBus";
import type { TimelineData } from "./timeline/types";
import {
CameraAspectRatioChangedEvent,
CamerasReplacedEvent,
@@ -71,6 +72,21 @@ export type SaveManagerDeps = {
getCameras: () => Camera[];
getSelectedCameraId: () => string;
// Animation timeline persistence (in-memory controller ↔ scene JSON).
getTimeline: () => TimelineData | null;
loadTimeline: (timeline: TimelineData) => void;
// Live render-camera (::CAM:: frustum) transform, read at save time so the
// persisted render-camera position reflects the actual scene object rather
// than the (possibly stale) store config. Null if no frustum exists.
getRenderCameraTransform: () => {
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
} | null;
// Recreate the ::CAM:: frustum after a load (the load path strips it). Must
// run AFTER the store cameras are restored so it reads the loaded config.
recreateCameraObject: () => void;
// Typed event bus — every engine→store write goes through here.
bus: EngineEventBus;
};
@@ -131,20 +147,31 @@ export class SaveManager {
const proxyScene = new StoryTellerProxyScene(version, scene);
const scene_json = proxyScene.saveToScene(version);
const camerasData = this.deps.getCameras().map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
// The render camera's true position lives on the live ::CAM:: frustum,
// not the store config (which goes stale on edit-mode gizmo moves). At
// save time, override the render-camera entry (first non-"main") with the
// live transform so a reload restores where the frustum actually is.
const renderTransform = this.deps.getRenderCameraTransform();
const renderCameraId = this.deps
.getCameras()
.find((cam) => cam.id !== "main")?.id;
const camerasData = this.deps.getCameras().map((cam: Camera) => {
const useLive = renderTransform && cam.id === renderCameraId;
return {
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: useLive ? renderTransform.position : cam.position,
rotation: useLive ? renderTransform.rotation : cam.rotation,
lookAt: cam.lookAt,
};
});
return {
version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: "",
timeline: this.deps.getTimeline() ?? null,
skybox: scene.skybox,
camera_data: {
position: camera?.position,
@@ -284,7 +311,24 @@ export class SaveManager {
}
this.deps.setVersion(scene_json["version"]);
// The load path stripped ::CAM::; recreate it now that the store cameras
// are restored (CamerasReplacedEvent above ran synchronously), so the
// frustum lands at the saved render-camera transform, then re-anchor it.
this.deps.recreateCameraObject();
this.deps.refreshCamObj();
// Restore the animation timeline. Guard legacy scenes where `timeline`
// was a `""` stub (or absent/null).
const timeline = scene_json["timeline"];
if (
timeline &&
typeof timeline === "object" &&
Array.isArray(timeline.tracks)
) {
this.deps.loadTimeline(timeline);
}
return true;
}
}
@@ -130,6 +130,9 @@ class Scene {
clear() {
this.scene.children = [];
// hot_items held detached keyframe-point refs across reloads; reset it
// so it never carries stale objects from a previous scene.
this.hot_items = [];
this._createGrid();
this._create_base_lighting();
this._create_skybox();
@@ -422,6 +425,11 @@ class Scene {
const renderCam = cameras.find((c) => c.id !== "main") ?? cameras[0];
if (!renderCam) return;
// Idempotent: drop any existing frustum so callers (initialize, clear,
// load-restore) never leave duplicate "::CAM::" objects.
const existing = this.scene.getObjectByName("::CAM::");
if (existing) this.scene.remove(existing);
const group = this._buildCameraFrustum();
group.name = "::CAM::";
group.userData["name"] = "Camera";
@@ -458,7 +466,26 @@ class Scene {
];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: 0xffffff });
group.add(new THREE.LineSegments(geometry, material));
const lines = new THREE.LineSegments(geometry, material);
// Disable line raycasting: THREE picks LineSegments with a 1-world-unit
// threshold, which dwarfs this ~0.7u frustum and selects it from far
// away. Selection is instead handled by the invisible pick-proxy below.
lines.raycast = () => {};
group.add(lines);
// Invisible pick-proxy sized to the frustum volume so clicks match the
// wireframe rather than a 1-unit halo. Raycastable (opacity 0, not
// mesh.visible=false) but draws nothing.
const proxyGeometry = new THREE.BoxGeometry(halfW * 2, halfH * 2, depth);
const proxyMaterial = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0,
depthWrite: false,
});
const proxy = new THREE.Mesh(proxyGeometry, proxyMaterial);
proxy.position.set(0, 0, -depth / 2);
group.add(proxy);
return group;
}
@@ -51,16 +51,31 @@ export const useViewportPointer = (
editor.mouse_controls?.onMouseClick();
};
// Double-click toggles camera view: anywhere exits when active; on the
// render-camera frustum (selected by the preceding click) it enters.
const onDoubleClick = () => {
if (cameraViewActive) {
editor.cameraController.exitCameraView();
return;
}
const selected = editor.sceneManager?.selected_objects?.[0];
if (selected?.name === "::CAM::") {
editor.cameraController.enterCameraView();
}
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("click", onClick);
canvas.addEventListener("dblclick", onDoubleClick);
return () => {
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("click", onClick);
canvas.removeEventListener("dblclick", onDoubleClick);
};
}, [canvas, editor, cameraViewActive]);
};