32 KiB
CLAUDE.md — pagescene (3D Scene Editor)
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.)
Behavior notes (fixes)
- Record mode is fully immutable. The viewport lock (
CameraController.locked, set by the record effect) gates every camera-move path: FreeCam drag/wheel/keyboard (useFreeCam), FreeCam integration (tickPerFrame), ANDMouseControlsmouse-look +focus()(gated viaMouseControlsDeps.isViewportLocked). If you add a new camera-move path, gate it onlocked. - Default camera framing (
DEFAULT_CAMERAS.main): a Blender-style pulled-back/elevated 3/4 view (pos (-4.5,4,6),lookAt (0,0.5,0.6)) so the whole render-camera (cam2) frustum is visible on a blank scene. - Auto-keyframe: mutating an already-keyframed object (gizmo drag-end or inspector commit)
calls
TimelineController.autoKeyIfTracked(uuid)→addKeyframeat the playhead, which replace-or-creates a keyframe at the current scrub point. No-op for never-keyframed objects (first keyframe is always explicit).
Architecture (existing, do not rebuild)
Strict one-way data flow. Do not violate it.
Three.js engine → typed event bus → EngineStoreBridge → Zustand store → React → actions → engine methods
Stage3D.tsx— public entry component; takes anadapter+ optional slots/callbacks.Stage3DBody.tsx— layout composition (canvas, panels, toolbars, modals). Mounts the externalPromptBox3D.PageSceneStore.ts— the single Zustand store (~60 fields). All UI state. The engine never imports this store.adapter.ts— platform-abstraction interface. All host-specific logic (HTTP, upload, auth, navigation, modals) flows throughPageSceneAdapter. Hosts (Tauri app, web app) inject it at mount.comps/Controls3D/Controls3D.tsx— top-center toolbar:+add, Move/Rotate/Scale (transformMode), World/Local (transformSpace).comps/Outliner/Outliner.tsx— left "Scene" panel (code name: Outliner). Driven byoutlinerItems; eachOutlinerRowhas lock + visibility toggles.engine/editor.ts— mainEditorclass; owns subsystems (GizmoController, CameraController, HistoryManager, SelectionBridge) and the prompt text (editor.positive_prompt).- Undo/redo —
HistoryManager+ command-patternUndoableActionclasses. Every mutation that should be undoable records an action.
Key boundary: the bottom prompt bar is NOT in this library. It is PromptBox3D from the external @storyteller/ui-promptbox package, mounted by Stage3DBody. Anything that changes the prompt bar crosses the pagescene ↔ ui-promptbox boundary.
Feature: Scene Builder (IN PROGRESS — branch feature/scene-builder)
Status: spec / in development. This section describes intended behavior; not all of it is built yet. Images referenced are from Figma design frames.
Top-level mode pill (net-new)
A segmented toggle at top-center (above the Controls3D toolbar) with two modes:
- Build — default. Scene composition/layout. Used unless the user switches.
- Record — read-only playback/output mode (see below).
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.
Launch re-scope — drop promptbox, lightbox handoff, editable duration
- Promptbox removed:
SceneBuilderPromptBoxdeleted (MCP not ready). The animation timeline is the sole build-mode bottom UI: collapsedTimelineBarby default, expandedTimelineEditorvia chevron. An empty timeline is auto-created on editor init (editor.timelineController.create()), so the bar is always functional (no "Add timeline" button). Auto-expands once on load whentimelineTracks.length > 0oreditor.sceneHasAnimation()(imported THREE clips). - Editable max duration:
TimelineController.setDuration(s)(clamped[1,60], covers 5–30s) +comps/Timeline/DurationLabel.tsx(click the total-time → number input). Persists viagetTimeline. - Capture/Record → review modal (appears first) → app Lightbox:
CompletionModalopens immediately for both kinds (producedArtifact) with a local preview (object URL, works offline) + upload status. Images auto-upload on open; videos show a manual Upload button (large). On success →adapter.openMediaLightbox(token, kind)(destinations) + close; on failure → Retry (so the modal is always visible even when the upload API is unreachable in dev). The modal — notRenderOverlay— owns the upload.RecordControls: Capture → produce imageproducedArtifact; Record → encode (overlay) → produce videoproducedArtifact.- Adapter:
openMediaLightbox(token, kind)(host resolves cdnUrl viaMediaFilesApiand renders the app<Lightbox>);uploadMediakept;openImageInEditor/openVideoInEditorremoved (the Lightbox owns destination routing — Edit-on-Canvas/Make-Video/Recreate/Share/Download). - Uploading first means the Lightbox gets a real token + cdnUrl → its actions route correctly (retires the earlier blob/data-URL handoff fragility).
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/replacedPromptBox3DinStage3DBody(the Manual/Prompted toggle +buildModestore field are gone;PromptBox3Dis no longer mounted by 3D). Update is still an MCP stub (records prompt + history on the engine). Hosts the collapsedTimelineBar+ Add-animation-timeline affordance. - Record mode = Capture (still) / Record (timeline → video):
engine/recording/TimelineRecorder.ts— deterministicseekTo→renderScene→CanvasSource.addencode via mediabunny (already a dep). Capture reusessnapShotOfCurrentFrame(false).comps/RenderOverlay/(opaqueLoadingDots+ progress) covers the viewport while encoding.- Output cached locally as
producedArtifactin 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 inapps/artcraft-webapp/.../pagescene: image → eager-upload +applyEditOnCanvasFromImage(/edit-image); video "generate" →useCreateVideoStore.setPendingRecreatereference-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_itemstoggle); 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.getSceneJsonnow writestimeline: getTimeline() ?? null(was a""stub);loadFromJsonrestores vialoadTimeline(guards legacy""/null). Flows through both backend save and the unmount→cache→remount roundtrip. Loaded objects keep their savedobject_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 (getRenderCameraTransformdep) so it reflects gizmo moves (the store config otherwise goes stale). The load path strips::CAM::and never recreated it — fixed:loadFromJsoncallsrecreateCameraObject()(→Scene._create_camera_obj(), now idempotent) AFTERCamerasReplacedEvent, so the frustum lands at the restored transform. This also fixes the standalone "load loses the render camera" bug. - Undo/redo: timeline is transactional —
Saveis one undo step (SaveTimelineAction;loadTimelinere-seeks + emits on undo/redo);Cancelreverts to last saved (not history). Render-camera gizmo moves were already undoable (TransformActionon::CAM::, no special-casing); because save reads the livecam_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 aboveControls3DinStage3DBody. Store:sceneMode(defaultbuild) +setSceneMode. - DONE — Manual/Prompted toggle + prompted layout: store
buildMode: "manual" | "prompted"(defaultmanual) +setBuildMode. The toggle + prompted layout live inPromptBox3D(@storyteller/ui-promptbox), gated by a new optionalbuildMode/onBuildModeChange/onAddTimelineprop trio passed fromStage3DBody. Toggle = aButtonIconSelect(Manual = pointer icon, Prompted = wand-sparkles) in a floating glass pill above the prompt card. Prompted layout hides the manual toolbar and shows: textarea + "Add animation timeline" (onAddTimeline, stubbed) + prompt-history popover (usePrompt3DStore.promptHistory) + Update.handlePromptedUpdateis a stub: records the prompt on the engine + pushes to history + toasts; it does NOT call the MCP backend yet. - DONE — Animation timeline (in-memory): keyframe system + playback + full UI.
- Data model:
engine/timeline/types.ts(Keyframe/TimelineTrack/TimelineData,EasingSpec= cubic-bezier ctrl pts, presets) +interpolation.ts(cubicBezierYForX,sampleTrackAt). ReusesTransformSnap/snapshotTransform/writeTransform. engine/editor/TimelineController.tsowns the timeline; ticked ineditor.tsrenderSingleFrame()afterentranceAnimator. Writes transforms only while playing or on seek (idle never touches objects, so manual editing is safe).- Events
TimelineChangedEvent/TimelinePlayheadEvent→EngineStoreBridge→ store fields (timelineExists/Expanded/Playhead/IsPlaying/Duration/Tracks/SelectedKeyframeId). Dispatchers inactions/timeline.ts. - Undo:
engine/editor/actions/SaveTimelineAction.ts;Saverecords it,Cancelreverts to last saved. - UI in
comps/Timeline/:TimelineBar(collapsed, via promptboxaboveStackSlot),TimelineEditor(expanded, replaces the prompt box;Stage3DBodyhidesPromptBox3DwhentimelineExpanded),TimelineTrackRow(diamonds, drag, add),MotionPopover(presets + draggable cubic-bezier).onAddTimeline→createTimeline+ expand;showAddTimelineButton={!timelineExists}hides the promptbox button once a timeline exists. - In-memory only — not persisted to scene JSON yet (reload clears). Rotation is euler-lerped (quaternion slerp is a possible refinement). Camera track is absent until the camera-split slice creates
::CAM::.
- Data model:
- DONE — Camera split (single camera) + camera view: the render camera is now a selectable
wireframe-frustum placeholder in the scene.
scene.ts_create_camera_obj()rewritten to build aTHREE.Groupnamed"::CAM::"(line frustum, layer 1 = editor-only/excluded from render), positioned at the first non-mainstore camera; called ininitialize()/clear();refreshCamObjresolves it. NouserData.media_id→ the save path (saveToScene, which only serializesmedia_idchildren) skips it, so scenes aren't corrupted.- Enter/exit camera view reuses the existing
CameraController.switchCameraView()+tickPerFramesync (canvas renders through the viewport camera, which is moved to the render camera — no render-path change). Trigger: newCameraViewToggleRequestedEvent→editor.tssubscription →switchCameraView(). Emitted by (a)scene_manager_api.tsdouble_click()when the selected object is::CAM::(covers viewport + outliner-row double-click), and (b)actions/cameraView.tstoggleCameraView(outliner icon + exit button). - Outliner:
OutlinerItem.isCamera(set inconvert_object) →Outliner.tsxrenders a view-from-camera button (faArrowRightToBracket) left of the lock icon. comps/ExitCameraView/shows the "EXIT CAMERA VIEW" pill whileeditorState === CAMERA_VIEW.
- DONE — Record mode:
sceneMode === "record"is read-only playback through the render camera.Stage3DBodyeffect onisRecord:cameraController.enterCameraView()+setLocked(true)on entry;setLocked(false)+exitCameraView()on exit.switchCameraView()was refactored into idempotententerCameraView()/exitCameraView(); newCameraController.lockedgates the FreeCam integration intickPerFrame(viewport can't be flown in record).- Build chrome hidden in record (ControlsTopButtons, Controls3D, top-right cluster, Outliner,
PreviewEngineCamera, ControlPanelSceneObject, PromptBox3D, TimelineEditor). The mode pill
stays.
ExitCameraViewis suppressed in record (the pill leaves record). comps/RecordControls/: bottom-center read-onlyTimelineBar(readOnlyhides the expand chevron — playback only, no editing) + Capture (editor.snapShotOfCurrentFrame(true)— still image download) and Record (red; stub — no video pipeline in pagescene yet).
- NOT STARTED: timeline persistence to scene JSON; natural-language motion clips; multi-camera;
render-camera transform round-trip to store
cameras[]; video render behind the Record button; the record-mode "expand timeline into a grayed-out read-only editor" (currently the expand chevron is simply hidden in record).
Feature: Mixamo animation clips on the timeline (IN PROGRESS)
Characters can carry skeletal (Mixamo) animation clips on the timeline, alongside the transform- keyframe tracks. Answers locked with the user: retarget onto the character's skeleton; clips + keyframes coexist; default clip source = the 37 curated demo clips. Playback is sequential, one clip at a time — a character's clips live on a single row and cannot overlap (see the overlap guard below). The earlier "stacked lanes" idea was dropped: stacking only pays off for additive layering (e.g. a base walk + an upper-body wave playing together), which needs per-lane weights + bone masking and is out of scope. If layering is ever wanted, that's the feature to build; until then one row per character is the right model.
- Data model (phase A, done):
engine/timeline/types.ts—ClipStrip(sourceMediaId,startTime,duration,loop) +ClipLane(objectUuid,strip);TimelineData.clipLanes[]. Only the reference + placement is serialized; theAnimationClipis resolved at runtime.TimelineControllerhasaddClipLane/moveClipLane/removeClipLane/clipLanesFor;TimelineChangedEventcarriesclipLanes→ storetimelineClipLanes. Persists via the existinggetTimeline/loadTimeline(ridesTimelineData). - Playback engine (phase B, done — UNVERIFIED at runtime):
engine/animation/CharacterAnimationManager.ts. OneTHREE.AnimationMixerper character; each lane → anAnimationAction. Playhead-driven + deterministic: on everyTimelineController.evaluate()it sets each action's absolutetimefrom the playhead (paused actions) thenmixer.update(0)— scrub, play, and frame-accurate record all pose identically.syncClipLanes()reconciles the runtime on every clip-lane change; a monotonic load token guards against superseded async clip loads. Clips are loaded viaScene.loadRawGlb(media_id)(raw GLTF, not added to the scene) and bound by node name — relies on the sharedmixorig:*naming so a clip's tracks resolve against the character's bones directly (direct-bind first;SkeletonUtils.retargetClipis the documented fallback if a rig uses different bone names — not yet wired, pending the runtime spike). - Trigger UI (done):
comps/AnimationsDrawer/— right-docked, only when a character is selected (build mode; hidden via CSS in record). Clips are click-to-add (drops at the playhead) and draggable onto the timeline (ANIMATION_CLIP_MIMEpayload{media_id, name}). - Timeline clip UI (phase C, done):
comps/Timeline/TimelineClipRow.tsxrenders one row per character (rendered even when empty, as a drop hint) holding all that character's strips end-to-end under its keyframe row inTimelineEditor. Strip body drags to move (moveClipLane), the right edge drags to trim length (resizeClipLane), a loop chip toggles repeat (setClipLoop), the × removes it (removeClipLane). Each character row is a drop target for clip drags (dropClipOnCharacter→addClipToCharacter(…, atTime), time from the ruler rect). Non-character rows ignore clip drags. No per-op undo by design — clip edits ride the timeline Save/Cancel session exactly like keyframes (cancel()restores + re-syncs). - Overlap guard (single row):
TimelineController.resolveFreeStartsnaps add/move to the nearest gap so a character's strips never overlap;nextStartAfterbounds trim (resizeClipLane) and auto-length (resolveClipDuration) against the following clip.evaluateAtthen has at most one active clip at any playhead, so playback is an unambiguous sequence. - Bind-pose in gaps: a disabled three.js action leaves the skeleton frozen on its last frame, so
evaluateAtresets any character with no clip under the playhead to its bind (T) pose viaresetToBindPose→Skeleton.pose()(skeletons cached per character ingetMixer). Gaps between strips, before the first, and after the last therefore show the default T-pose, not a held frame. - Drag preview: dragging a clip drives the shared
DragGhost(tilt card) by settingdragItem/dragPosition/assetDraggingUnderon the store in the drawer's native-DnD handlers, and suppresses the browser's default drag image — matching object/character pickup motion. - Real clip length (fix, done): a fresh drop seeds
strip.durationwith a placeholder and flagsautoDuration; when the GLB loads,CharacterAnimationManagercallsTimelineController.resolveClipDuration(laneId, clip.duration)to adopt the clip's true length (and re-clamp start). A user trim clearsautoDurationso the natural length never clobbers a hand-set duration on reload. - Rig-mismatch diagnostic:
CharacterAnimationManager.clipBindsToCharacterwarns (console) when a clip's tracks resolve to 0 nodes on the character — the signal thatSkeletonUtils.retargetClipis needed. Retarget itself is still not wired (direct-bind first). - ⚠️ Runtime unknown to verify: whether the demo clips and character rigs share
mixamorig:*bone names (→ direct bind works) or need retargeting. Drop/scrub a clip and watch for motion (and the console warn) to decide whether to wire the retarget fallback.
⚠️ Unverified — compiles but NOT runtime-tested
Everything in slices 1–3 has been validated only by tsc -b (both pagescene and
promptbox exit 0). The app has not been launched; no visual, behavioral, lint, or test
verification has been done. Outstanding items to verify live:
-
Slice 1 (pill): placement/spacing above
Controls3D(possible doublept-3); white-active styling vs Figma; that clicking actually flipssceneMode. -
Slice 2 (prompted): Manual/Prompted toggle renders/positions correctly above the card; prompted layout hides the toolbar and shows textbox + add-timeline + history + Update; Update stub toasts + records history; history popover lists/restores prompts; toggle only appears when the host wires
onBuildModeChange. -
Slice 3 (timeline): nothing behaviorally verified — playback actually interpolates objects; scrub/seek updates transforms; add/move/delete keyframes; Save+undo / Cancel; collapsed bar positioning via
aboveStackSlot; expanded editor replacing the prompt box; playhead-line alignment (theLANE_LEFT=8.75rem/LANE_RIGHT=2.5remoffsets are hand-computed guesses);MotionPopoverbezier drag coordinate math + popover anchoring; range-scrubber styling; euler rotation lerp quality; the assumption that the render loop stays alive during playback (shouldRender); "selected object gets an empty track row" behavior. -
Slice 4 (camera split): nothing runtime-verified — that the frustum appears at the render camera and is excluded from the render; that selecting/moving it moves the render framing; double-click (viewport + outliner row) and the outliner icon enter camera view; the aspect frame + focal label show in camera view; EXIT returns to the prior free-cam position; the frustum is/ isn't viewport-click-selectable (may need
raycaster.layers.enable(1)in MouseControls — outliner selection works regardless);::CAM::really is skipped by save. -
Slice 5 (record mode): nothing runtime-verified — that switching the pill to Record enters a locked render-camera view (viewport truly can't be flown), hides all build chrome but keeps the pill, and shows the read-only playback bar + Capture/Record; that Capture downloads the composed frame; that switching back to Build restores the prior free-cam view and chrome; interaction between record and a manually-entered camera view; whether
switchCameraViewrefactor (idempotent enter/exit) preserved the original toggle behavior. Record button is a no-op stub.
Treat all of the above as needing a live pass before shipping.
Build mode has two usability sub-modes
-
Manual — identical to current behavior. User manually moves/places/transforms objects with the gizmo and toolbar. This is what exists today.
-
Prompted (Builder) — the prompt bar changes to show only:
- a text box,
- an "Add animation timeline" button,
- a history button (previous prompts),
- an "Update" primary button.
Prompted mode drives a JSON-style, MCP-esque backend integration (in development). The flow: an LLM (e.g. Claude) reads the scene's scene-descriptor data + the user's prompt, makes edits, outputs a new scene descriptor, which is then applied to the 3D scene.
The "Add animation timeline" button only appears when the scene is static (no keyframes/animations on any object). Clicking it reveals the animation timeline bar (see below).
Animation timeline
Collapsed state (a slim bar that sits above the prompt box, replacing the "Add animation timeline" button once a timeline exists):
- ▶ play/pause · current-time readout (
0:00) · scrubber (blue fill to playhead) · total duration (0:10) · chevron ▾. - The chevron ▾ expands the timeline downward, collapsing the prompt box (they trade vertical space).
Expanded state (full multi-track keyframe editor):
- Transport (top-left): skip-to-start ⏮ · play ▶ · skip-to-end ⏭.
- Time ruler with a draggable playhead (
0:00 … 0:04 … 0:10). - One track per object. A track can hold either:
- Diamond keyframes — each diamond stores the object's full transform (position, rotation, scale) at that moment. Tapping a diamond jumps the playhead to it. Each row has an "add keyframe" diamond button at its right edge.
- Natural-language motion clip — a text clip spanning a time range (e.g. "walks to car - opens door - gets in") that drives generated motion.
- Any object can have either type. For simplicity, an early version may show only raw keyframes and drop the natural-language grouping.
- Motion popover — appears when a keyframe is selected; edits the easing/interpolation curve into the next keyframe. Contains ~4 easing presets at top plus a large editable bezier curve with draggable handles; a dotted line shows the linear reference.
- Cancel / Save (bottom-right):
- Cancel — drops all changes back to the last saved timeline state.
- Save — commits the changes. This is an undoable action (record it via
HistoryManager/ anUndoableAction).
Camera model change (Blender-style)
Introduce a distinction between:
- Viewport (scene) camera — what the user flies around with; the editing view.
- Render camera(s) — the camera(s) that define the final render output.
Render cameras become objects within the 3D scene: visible/selectable in the editor (with a camera gizmo/frustum, as in the Figma), but excluded from the final render. This mirrors professional 3D software (Blender).
Render cameras appear in the Outliner (left "Scene" panel). A camera row shows a "view from camera" icon positioned right of the camera item's name and left of the lock icon (visible in the reference frames). Clicking it switches the viewport camera to the selected render camera and lets the user control it with the usual navigation keybinds.
This is the big architectural change — but the engine is already partially scaffolded for it. Verify these anchors before building on them:
- Two cameras already exist in
engine/editor/CameraController.ts:camera(viewport/free-fly, sees layer 1 incl. gizmos) andrender_camera(layer 0 only, clean output, its own aspect ratio). Created ineditor.tsinitialize()from the store's camera configs. Cameratype lives infrontend/libs/common/src/lib/interfaces/Camera.ts:{ id, label, focalLength, position, rotation, lookAt }. Store seeds two:"main"(Main View, viewport) and"cam2"(Camera 2, render). So the store models multiple camera configs, but only onerender_cameraTHREE object is currently instantiated (first non-main).- Placeholder object
"::CAM::"is referenced throughout (scene.getObjectByName("::CAM::"),CameraController.refreshCamObj, layer 1 so it's editor-only/excluded from render) — but_create_camera_obj()is commented out inscene.ts, so no camera mesh is actually in the scene graph today. The Outliner already knows to render a::CAM::object as a "Camera" row (scene_manager_api.tsconvert_object) — it just never appears because the object isn't created. CAMERA_VIEWstate (enums/EditorState.ts) +CameraController.switchCameraView()already implement enter/exit: on enter it copiescam_objtransform → viewport camera, hidescam_obj/objects/gizmo; on exit it restores. Caveat: today it repositions the single viewport camera rather than truly swapping to render through a separate scene camera.PreviewEngineCamera.tsx+ an unusedcamViewCanvasElexist as a secondary preview surface.- Letterbox/aspect overlay already built:
comps/SceneContainer/Letterbox.tsxreadscameraAspectRatio, draws mattes, toggled viaeditorLetterBox.CameraAspectRatioenum +getRenderDimensions()map to pixel sizes. - Focal length is per-camera (
Camera.focalLength), converted viaCameraController.focalLengthToFov(), applied per-frame intickPerFrame();focalLengthDraggingtracks the slider. The35mmoverlay label maps to this. - Render path:
editor.tsrenderScene()rendersactiveScene.scenethroughrender_camera; butsnapShotOfCurrentFrame()currently snapshots through the viewportcamera, notrender_camera— reconcile so exports use the render camera.
Gaps to close for the true split: (1) actually create camera placeholder meshes as scene objects (uncomment/rework _create_camera_obj); (2) map cameras: Camera[] 1:1 to scene objects; (3) make CAMERA_VIEW swap which camera the viewport renders through (not just reposition one); (4) support N render cameras, not just the first non-main; (5) make cameras keyframeable timeline targets. Reconcile all of this with the existing cameras[] + selectedCameraId store state.
Camera view
Entered by double-clicking the camera placeholder in the viewport or the view-from-camera button in the outliner. In this state the viewport camera becomes the selected render camera — the user flies it with the usual navigation keybinds, and that motion is what gets keyframed.
- Framing overlay: the render aspect frame with rule-of-thirds guides; everything outside the frame is dimmed (won't be captured).
- Focal-length label (e.g.
35mm) shown top-left inside the frame. Editable; may become a keyframable value in the future. - The camera's frustum is not drawn while looking through it.
- Timeline filters to just the selected camera's track while in camera view.
- Selected keyframe renders as a blue diamond (at the playhead); other keyframes are white.
- Exit via the EXIT CAMERA VIEW button (bottom-right), or Esc / double-click out. (The text button is a dev placeholder; a proper icon comes later.)
Camera keyframes store the camera's transform just like object keyframes (and later, potentially focal length).
Record mode
Record mode is the read-only output/preview mode — practically immutable. It plays back the render timeline as seen from the main render camera only.
- All build chrome is hidden: no outliner, no transform toolbar, no prompt box, no gizmos.
- The viewport is locked to the render camera (no free-fly, no object/camera editing). Framing overlay (aspect frame, dimmed exterior) is shown.
- The only interactive controls are play/pause and scrubber position. Nothing in the scene can be mutated.
- The chevron can still expand the timeline, but it renders grayed out / read-only — no keyframe editing.
- Two actions:
- Capture — export the current frame as a still image.
- Record — render the full animation to video.
Build ↔ Record relationship (summary of axes)
- Top pill:
sceneMode=build | record(net-new). - Within Build: a usability sub-mode =
manual | prompted. - Camera view is an orthogonal viewport state (looking through a render camera) available while editing — not a top-level mode.
- Keep these distinct from the pre-existing
editorState(EDIT | CAMERA_VIEW) andposeMode(select | pose) fields; do not overload them.
Notes / deferred
- JSON scene descriptor / MCP backend contract is out of scope for the UI work — the UI and other features can be built independently. The one thing that won't function until the backend lands is the Update button in Prompted mode (acceptable for dev).
- The view-from-camera icon is already shown in the reference frames (outliner, right of the camera name, left of the lock icon) — no separate spec needed.
- EXIT CAMERA VIEW text button is a dev placeholder; a proper icon comes later.