mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-14 13:52:11 +00:00
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:
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;
|
||||
|
||||
+50
@@ -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";
|
||||
+70
-18
@@ -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";
|
||||
+127
@@ -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]);
|
||||
};
|
||||
Reference in new issue
Block a user