diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6374cbb40..42e6ab9ee 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -156,6 +156,11 @@ export interface ThumbnailGenerateEvent { projectId: string captureMode?: 'standard' | 'viewport' | 'area' cropRegion?: { x: number; y: number; width: number; height: number } + /** + * Output size for `standard` captures (center-crop target). Defaults to + * 1920×1080; the capture overlay passes other aspect presets (9:16, 4:3…). + */ + standardSize?: { w: number; h: number } /** * When true, snap levels to their true positions before capturing (for a * consistent auto-thumbnail angle) and defer the capture if the tab is diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index e3ed4aa63..f9099c600 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -150,11 +150,13 @@ function RightColumn({ toolbarRight, children, overlays, + stageOverlay, }: { toolbarLeft?: ReactNode toolbarRight?: ReactNode children: ReactNode overlays?: ReactNode + stageOverlay?: ReactNode }) { return (
{children}
+ {/* Stage overlay — replaces the canvas visually (e.g. studio gallery) + while keeping it mounted. Sits below the viewer toolbar (z-20) so + the stage switch stays reachable. */} + {stageOverlay &&
{stageOverlay}
} {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the draggable region the floating inspector clamps itself to. */} {overlays && ( @@ -200,6 +206,7 @@ export interface EditorLayoutV2Props { viewerToolbarRight?: ReactNode viewerContent: ReactNode overlays?: ReactNode + stageOverlay?: ReactNode } export function EditorLayoutV2({ @@ -211,6 +218,7 @@ export function EditorLayoutV2({ viewerToolbarRight, viewerContent, overlays, + stageOverlay, }: EditorLayoutV2Props) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const isMobile = useIsMobile() @@ -246,6 +254,7 @@ export function EditorLayoutV2({ )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d3a19d677..d12e33f95 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -137,6 +137,12 @@ export interface EditorProps { sidebarTabs?: (SidebarTab & { component: React.ComponentType })[] viewerToolbarLeft?: ReactNode viewerToolbarRight?: ReactNode + /** + * Full-bleed surface swapped in over the 3D canvas (v2) — e.g. the studio + * gallery. The canvas stays mounted underneath (no WebGL re-init) and the + * viewer toolbar stays on top so the host's stage switch remains reachable. + */ + stageOverlay?: ReactNode /** * Docked below the node inspector (v2). Hosts mount the "save as preset" * affordance here so it reads as part of the inspector surface and shows @@ -1077,6 +1083,7 @@ export default function Editor({ sidebarTabs, viewerToolbarLeft, viewerToolbarRight, + stageOverlay, inspectorFooter, projectId, onLoad, @@ -1364,7 +1371,7 @@ export default function Editor({ navbarSlot={navbarSlot} overlays={ <> - {!isCaptureMode && } + {!(isCaptureMode || stageOverlay) && } {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
@@ -1392,6 +1399,7 @@ export default function Editor({ renderTabContent={renderTabContent} sidebarOverlay={sidebarOverlay} sidebarTabs={tabBarTabs} + stageOverlay={stageOverlay} viewerContent={viewerCanvas} viewerToolbarLeft={viewerToolbarLeft} viewerToolbarRight={viewerToolbarRight} diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index d3732e751..c8710c031 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -1,17 +1,20 @@ 'use client' import { emitter } from '@pascal-app/core' -import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react' +import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useIsMobile } from '../../hooks/use-mobile' import { triggerSFX } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' - -// Local crop-mode enum — distinct from `useEditor.captureMode` (which -// describes *why* a capture is happening, e.g. `preset`). This one says -// HOW the captured pixels are cropped: full-frame 16:9 (`standard`), -// raw canvas viewport, or user-dragged area. -type CropMode = 'standard' | 'viewport' | 'area' +import useEditor, { + type SnapshotCropMode, + type SnapshotStandardAspect, +} from '../../store/use-editor' + +// Local alias — distinct from `useEditor.captureMode` (which describes *why* +// a capture is happening, e.g. `preset`). This one says HOW the captured +// pixels are cropped: full-frame 16:9 (`standard`), raw canvas viewport, or +// user-dragged area. Hosts can preselect it via `captureMode.crop`. +type CropMode = SnapshotCropMode type CaptureState = 'idle' | 'capturing' | 'saved' interface DragPoint { @@ -24,12 +27,23 @@ interface Drag { end: DragPoint } +// Output presets for `standard` captures — long edge stays near 1920. +const STANDARD_SIZES: Record = { + '16:9': { w: 1920, h: 1080 }, + '9:16': { w: 1080, h: 1920 }, + '4:3': { w: 1920, h: 1440 }, + '3:4': { w: 1440, h: 1920 }, + '1:1': { w: 1440, h: 1440 }, +} +type StandardAspect = SnapshotStandardAspect + function getResolution( mode: CropMode, overlayEl: HTMLDivElement | null, drag: Drag | null, + standardAspect: StandardAspect, ): { w: number; h: number } | null { - if (mode === 'standard') return { w: 1920, h: 1080 } + if (mode === 'standard') return STANDARD_SIZES[standardAspect] if (!overlayEl) return null const rect = overlayEl.getBoundingClientRect() @@ -49,6 +63,41 @@ function getResolution( return null } +/** Rule-of-thirds guide rendered inside a framing surface. */ +function ThirdsGrid() { + return ( +
+ ) +} + +/** Accented corner brackets on the framing rect. */ +function CornerAccents() { + return ( + <> + + + + ) +} + +const HUD_CHIP_CLASS = + 'flex flex-col gap-px rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 backdrop-blur-md' + +const CROP_LABELS: Record = { + standard: 'Standard', + viewport: 'Viewport', + area: 'Area', +} + export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const captureMode = useEditor((s) => s.captureMode) @@ -58,12 +107,29 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { // a transparent background — the user picks framing but not the // crop shape. Matches the unified preset-thumbnail capture flow. const isPreset = captureMode.mode === 'preset' + const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined + const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined const [mode, setMode] = useState('standard') + const [standardAspect, setStandardAspect] = useState('16:9') + const [aspectMenuOpen, setAspectMenuOpen] = useState(false) const [drag, setDrag] = useState(null) const [isDragging, setIsDragging] = useState(false) const [captureState, setCaptureState] = useState('idle') const overlayRef = useRef(null) + // Overlay size drives the computed standard-frame rect + resolution HUD. + const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null) + + useEffect(() => { + if (!isCaptureMode) return + const el = overlayRef.current + if (!el) return + const update = () => setOverlaySize({ w: el.clientWidth, h: el.clientHeight }) + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, [isCaptureMode]) // Dismiss on Esc useEffect(() => { @@ -82,7 +148,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { // tweak the framing, but they don't have to draw the rect first. useEffect(() => { if (!isCaptureMode) return - setMode(isPreset ? 'area' : 'standard') + setMode(isPreset ? 'area' : (requestedCrop ?? 'standard')) + setStandardAspect(requestedAspect ?? '16:9') + setAspectMenuOpen(false) setIsDragging(false) setCaptureState('idle') if (isPreset && overlayRef.current) { @@ -97,7 +165,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { } else { setDrag(null) } - }, [isCaptureMode, isPreset]) + }, [isCaptureMode, isPreset, requestedCrop, requestedAspect]) // Listen for snapshot saved to show feedback then exit useEffect(() => { @@ -265,16 +333,36 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { projectId, captureMode: mode, cropRegion, + standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined, // In preset mode, the ThumbnailGenerator should keep the alpha // channel transparent so the saved preset thumbnail composes // cleanly onto any palette background. transparent: isPreset, }) - }, [captureState, mode, drag, projectId, isPreset]) + }, [captureState, mode, drag, projectId, isPreset, standardAspect]) if (!isCaptureMode) return null - const resolution = getResolution(mode, overlayRef.current, drag) + const resolution = getResolution(mode, overlayRef.current, drag, standardAspect) + + // Standard mode framing: the output is a center-crop of the canvas to the + // chosen aspect (see ThumbnailGenerator) — show exactly that region as a + // letterboxed frame. + const standardFrame = + mode === 'standard' && overlaySize + ? (() => { + const size = STANDARD_SIZES[standardAspect] + const targetRatio = size.w / size.h + const w = Math.min(overlaySize.w, overlaySize.h * targetRatio) + const h = w / targetRatio + return { + left: (overlaySize.w - w) / 2, + top: (overlaySize.h - h) / 2, + width: w, + height: h, + } + })() + : null // Area selection rect (CSS px, relative to overlay) const selectionStyle = @@ -294,6 +382,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return (
+ {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */} + {standardFrame && ( +
+ + +
+ )} + + {/* Viewport mode: the whole canvas is the frame — thirds only */} + {mode === 'viewport' && } + {/* Area mode: dim layer + crosshair cursor + drag-to-select. * * Preset mode reuses the same DOM but stays click-through: the @@ -336,7 +441,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { has a pre-staged square, so we never show it there. */} {!selectionStyle && !isPreset && (
- + Drag the area you want to capture
@@ -345,6 +450,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { {/* Selection rect */} {selectionStyle && (
+ {hasSelection && } + {/* Corner handles — preset mode locks the frame to the auto-staged centered square; the user adjusts the camera instead. */} @@ -390,11 +497,33 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
)} + {/* Top-center HUD — what the shot will be */} + {!isMobile && ( +
+
+ + Crop + + + {isPreset ? 'Preset · square' : CROP_LABELS[mode]} + +
+
+ + Format + + + {resolution ? `${resolution.w} × ${resolution.h}` : '—'} + +
+
+ )} + {/* Top-right dismiss button (icon-only on mobile) */}
- {/* Bottom-center mode toolbar */} -
- {(() => { - // Preset capture mode locks both the crop shape (square) and - // the transparent-background output — hide the per-shape - // mode buttons so the user has nothing to second-guess. - const modeButtons = isPreset ? null : ( - <> - } - label="Standard" - onClick={() => { - setMode('standard') - setDrag(null) - }} - /> - } - label="Viewport" - onClick={() => { - setMode('viewport') - setDrag(null) - }} - /> - } - label="Area" - onClick={() => setMode('area')} - /> - - ) - - const resolutionDisplay = ( - - {resolution ? `${resolution.w} × ${resolution.h}` : '—'} - - ) - - const captureButton = ( - - ) - - if (isMobile) { - return ( -
- {modeButtons && ( -
{modeButtons}
- )} -
- {resolutionDisplay} - {captureButton} -
+ {/* Subtle scrim so the bottom controls stay readable on bright scenes */} +
+ + {/* Bottom-center: crop switcher, caption + shutter */} +
+ {!isPreset && ( +
+ {/* Clicking Standard while it's active opens the aspect picker */} + } + label={isMobile ? undefined : 'Standard'} + onClick={() => { + if (mode === 'standard') setAspectMenuOpen((v) => !v) + else setAspectMenuOpen(false) + setMode('standard') + setDrag(null) + }} + /> + {aspectMenuOpen && mode === 'standard' && ( +
+ {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => ( + + ))}
- ) - } + )} + } + label={isMobile ? undefined : 'Viewport'} + onClick={() => { + setMode('viewport') + setDrag(null) + setAspectMenuOpen(false) + }} + /> + } + label={isMobile ? undefined : 'Area'} + onClick={() => { + setMode('area') + setAspectMenuOpen(false) + }} + /> + {isMobile && ( + + {resolution ? `${resolution.w} × ${resolution.h}` : '—'} + + )} +
+ )} - return ( -
- {modeButtons} - {modeButtons &&
} - {resolutionDisplay} -
- {captureButton} -
- ) - })()} + {!isMobile && ( + + A snapshot + {' freezes this exact camera angle as a reusable reference for renders & videos.'} + + )} + + + + {captureState === 'capturing' + ? 'Capturing…' + : captureState === 'saved' + ? 'Saved' + : 'Take snapshot'} +
) @@ -515,7 +650,7 @@ function ModeButton({ }: { active: boolean icon: React.ReactNode - label: string + label?: string badge?: string onClick: () => void }) { diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 72929e3f7..72c840a85 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -151,7 +151,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro snapLevels: boolean, captureMode?: 'standard' | 'viewport' | 'area', cropRegion?: { x: number; y: number; width: number; height: number }, + standardSize?: { w: number; h: number }, ) => { + const standardW = standardSize?.w ?? THUMBNAIL_WIDTH + const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT if (isGenerating.current) return if (!onThumbnailCaptureRef.current) return @@ -342,9 +345,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) blob = await offscreen.convertToBlob({ type: 'image/png' }) } else { - // Standard: center-crop to 1920×1080 aspect ratio + // Standard: center-crop to the requested aspect (default 1920×1080) const srcAspect = width / height - const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT + const dstAspect = standardW / standardH let sx = 0, sy = 0, sWidth = width, @@ -356,8 +359,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro sHeight = Math.round(width / dstAspect) sy = Math.round((height - sHeight) / 2) } - outW = THUMBNAIL_WIDTH - outH = THUMBNAIL_HEIGHT + outW = standardW + outH = standardH const offscreen = new OffscreenCanvas(outW, outH) offscreen .getContext('2d')! @@ -414,7 +417,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro ) } else { const srcAspect = width / height - const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT + const dstAspect = standardW / standardH let sx = 0, sy = 0, sWidth = width, @@ -426,8 +429,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro sHeight = Math.round(width / dstAspect) sy = Math.round((height - sHeight) / 2) } - outW = THUMBNAIL_WIDTH - outH = THUMBNAIL_HEIGHT + outW = standardW + outH = standardH const offscreen = document.createElement('canvas') offscreen.width = outW offscreen.height = outH @@ -468,6 +471,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const handleGenerateThumbnail = async (event: { captureMode?: 'standard' | 'viewport' | 'area' cropRegion?: { x: number; y: number; width: number; height: number } + standardSize?: { w: number; h: number } snapLevels?: boolean // `transparent` is informational here — the render pipeline already // captures with alpha (see `setClearAlpha(0)` above) — the flag is @@ -475,7 +479,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // background bits) can branch on it without touching the emitter. transparent?: boolean }) => { - await generate(event.snapLevels === true, event.captureMode, event.cropRegion) + await generate( + event.snapLevels === true, + event.captureMode, + event.cropRegion, + event.standardSize, + ) } emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b06755068..6c513c759 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -365,7 +365,10 @@ export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { + CaptureMode, FloorplanSelectionTool, + SnapshotCropMode, + SnapshotStandardAspect, SplitOrientation, Tool, ToolDefaults, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index de237275a..ec9a9eab1 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -82,9 +82,16 @@ export type WorkspaceMode = 'edit' | 'studio' // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and // applies those constraints. Keeping it a discriminated union lets us // add future modes without surfacing the choice to end users. +// How the captured pixels are cropped: full-frame 16:9, raw canvas viewport, +// or user-dragged area. Hosts (e.g. the studio capture bar) can preselect it +// when entering capture mode. +export type SnapshotCropMode = 'standard' | 'viewport' | 'area' +/** Aspect presets available to `standard` crops. */ +export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1' + export type CaptureMode = | { mode: 'idle' } - | { mode: 'standard' } + | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect } | { mode: 'preset' isolated: AnyNodeId[]