diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx index bf563466e2..df7760c472 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx @@ -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((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(null); useEffect(() => { @@ -272,6 +349,8 @@ function PageSceneEditor() { userToken: user?.user_token, initialSceneToken: sceneToken, navigateToImageTo3D, + openImageInEditor, + openVideoInEditor, getViewportSize, promptSignup: openSignupCta, onRequestNewSceneSelector: openSceneSplash, diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx index 77b1b71b69..a64f7e37c6 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx @@ -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, diff --git a/frontend/libs/components/pagescene/CLAUDE.md b/frontend/libs/components/pagescene/CLAUDE.md index f4b43ac4f6..cd5079f3fc 100644 --- a/frontend/libs/components/pagescene/CLAUDE.md +++ b/frontend/libs/components/pagescene/CLAUDE.md @@ -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`. diff --git a/frontend/libs/components/pagescene/src/index.ts b/frontend/libs/components/pagescene/src/index.ts index 3efaa87a52..785a46be4c 100644 --- a/frontend/libs/components/pagescene/src/index.ts +++ b/frontend/libs/components/pagescene/src/index.ts @@ -7,6 +7,7 @@ export type { Stage3DProps } from "./lib/Stage3D"; // Adapter (platform abstraction) export type { PageSceneAdapter, + PageSceneArtifact, PageSceneGenerateRequest, PageSceneSavePayload, ListMediaFilesQuery, diff --git a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts index 5bca339f51..df291c831f 100644 --- a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts +++ b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts @@ -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((set, get) => ({ editorState: EditorStates.EDIT, sceneMode: "build", - buildMode: "manual", transformMode: "move", transformSpace: "world", selectedMode: "move", @@ -358,6 +380,8 @@ export const usePageSceneStore = create((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((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((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) => { diff --git a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx index adb646fc6d..04240354ec 100644 --- a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx +++ b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx @@ -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 = ({ + +
{!isRecord && } + {!isRecord && ( +
+ +
+ )} {!isRecord && topBarStartSlot && (
{topBarStartSlot}
)} @@ -497,15 +506,19 @@ export const Stage3DBody = ({ className="absolute bottom-0 left-0 right-0" onClick={handleOverlayClick} > - {!isRecord && ( -
- - -
- )} + {/* 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. */} +
+ + +
{!isRecord && }
@@ -516,68 +529,7 @@ export const Stage3DBody = ({ {isRecord && } - {!timelineExpanded && !isRecord && ( - - editor?.bus.emit(new GridVisibleChangedEvent(visible)) - } - selectedCameraId={selectedCameraId} - deleteCamera={deleteCamera} - focalLengthDragging={focalLengthDragging} - setFocalLengthDragging={setFocalLengthDragging} - isPromptBoxFocused={isPromptBoxFocused} - setIsPromptBoxFocused={setIsPromptBoxFocused} - uploadImage={ - editor - ? (((arg: Parameters[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={ - <> - - {timelineExists && !timelineExpanded && } - {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 && } ; + // Auth/logout — the SettingsModal in Controls3D needs a logout // callback. Tauri host: setLogoutStates. Web host: web auth flow. performLogout(): void; diff --git a/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/CameraStatusPill.tsx b/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/CameraStatusPill.tsx new file mode 100644 index 0000000000..fa7dbdb3e8 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/CameraStatusPill.tsx @@ -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 ( + + + + ); +}; + +export default CameraStatusPill; diff --git a/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/index.tsx b/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/index.tsx new file mode 100644 index 0000000000..cc744ffb07 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/CameraStatusPill/index.tsx @@ -0,0 +1 @@ +export { CameraStatusPill } from "./CameraStatusPill"; diff --git a/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/CompletionModal.tsx b/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/CompletionModal.tsx new file mode 100644 index 0000000000..e708482740 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/CompletionModal.tsx @@ -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 ( +
+
+
+ {isVideo ? "Recording complete" : "Capture complete"} +
+ + {/* preview */} +
+ {isVideo ? ( +
+ + {/* actions */} +
+ + +
+ + + {isVideo ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ ); +}; + +export default CompletionModal; diff --git a/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/index.tsx b/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/index.tsx new file mode 100644 index 0000000000..a64c2ebc8c --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/CompletionModal/index.tsx @@ -0,0 +1 @@ +export { CompletionModal } from "./CompletionModal"; diff --git a/frontend/libs/components/pagescene/src/lib/comps/RecordControls/RecordControls.tsx b/frontend/libs/components/pagescene/src/lib/comps/RecordControls/RecordControls.tsx index 8c4da0cbef..77c153ce07 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/RecordControls/RecordControls.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/RecordControls/RecordControls.tsx @@ -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 = () => { - + +
); diff --git a/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/RenderOverlay.tsx b/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/RenderOverlay.tsx new file mode 100644 index 0000000000..846de9c8b9 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/RenderOverlay.tsx @@ -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 ( + + ); +}; + +export default RenderOverlay; diff --git a/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/index.tsx b/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/index.tsx new file mode 100644 index 0000000000..10d29ae50e --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/RenderOverlay/index.tsx @@ -0,0 +1 @@ +export { RenderOverlay } from "./RenderOverlay"; diff --git a/frontend/libs/components/pagescene/src/lib/comps/SceneBuilderPromptBox/SceneBuilderPromptBox.tsx b/frontend/libs/components/pagescene/src/lib/comps/SceneBuilderPromptBox/SceneBuilderPromptBox.tsx new file mode 100644 index 0000000000..5f4b54f7f8 --- /dev/null +++ b/frontend/libs/components/pagescene/src/lib/comps/SceneBuilderPromptBox/SceneBuilderPromptBox.tsx @@ -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 ( +
+ {timelineExists && !timelineExpanded && } + +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > +
+ +