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 && (
+ {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 && (
+