feat(editor): stage overlay slot + pro capture overlay for studio (#465)
* feat(editor): stage overlay slot, preselectable snapshot crop, pro capture overlay - Editor/EditorLayoutV2 gain a stageOverlay slot rendered over the canvas but under the viewer toolbar (z-10 < z-20), so hosts can swap in a full-stage surface (e.g. studio gallery) without unmounting the WebGL canvas; FloatingLevelSelector hides while a stage overlay is active - CaptureMode 'standard' accepts an optional crop preselection (SnapshotCropMode, exported) so hosts can enter capture with standard/viewport/area already chosen - SnapshotCaptureOverlay restyle: letterboxed 16:9 frame with corner accents + rule-of-thirds for standard mode, thirds grid in viewport and area modes, top-center crop/format HUD chips, shutter button with capture/saved states — all existing crop, drag, resize and preset behaviors preserved Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): standard-capture aspect presets, capture scrim, caption fix - Standard crop gains aspect presets (16:9, 9:16, 4:3, 3:4, 1:1) — clicking the active Standard chip opens the picker; the letterbox frame, HUD and output resolution follow the choice - ThumbnailGenerateEvent carries standardSize; ThumbnailGenerator center-crops to it (default stays 1920×1080) - Subtle bottom scrim keeps shutter/caption readable on bright scenes - Fix missing space in the snapshot caption Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): standard-capture aspect preselect via CaptureMode Hosts (the studio capbar) can now pass standardAspect alongside crop when entering capture mode; the overlay initializes its aspect picker from it. SnapshotStandardAspect exported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22af31dea8
commit
9b5e229770
@@ -156,6 +156,11 @@ export interface ThumbnailGenerateEvent {
|
|||||||
projectId: string
|
projectId: string
|
||||||
captureMode?: 'standard' | 'viewport' | 'area'
|
captureMode?: 'standard' | 'viewport' | 'area'
|
||||||
cropRegion?: { x: number; y: number; width: number; height: number }
|
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
|
* When true, snap levels to their true positions before capturing (for a
|
||||||
* consistent auto-thumbnail angle) and defer the capture if the tab is
|
* consistent auto-thumbnail angle) and defer the capture if the tab is
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ function RightColumn({
|
|||||||
toolbarRight,
|
toolbarRight,
|
||||||
children,
|
children,
|
||||||
overlays,
|
overlays,
|
||||||
|
stageOverlay,
|
||||||
}: {
|
}: {
|
||||||
toolbarLeft?: ReactNode
|
toolbarLeft?: ReactNode
|
||||||
toolbarRight?: ReactNode
|
toolbarRight?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
overlays?: ReactNode
|
overlays?: ReactNode
|
||||||
|
stageOverlay?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -174,6 +176,10 @@ function RightColumn({
|
|||||||
)}
|
)}
|
||||||
{/* Canvas area */}
|
{/* Canvas area */}
|
||||||
<div className="relative flex-1 overflow-hidden">{children}</div>
|
<div className="relative flex-1 overflow-hidden">{children}</div>
|
||||||
|
{/* 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 && <div className="absolute inset-0 z-10">{stageOverlay}</div>}
|
||||||
{/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
|
{/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
|
||||||
draggable region the floating inspector clamps itself to. */}
|
draggable region the floating inspector clamps itself to. */}
|
||||||
{overlays && (
|
{overlays && (
|
||||||
@@ -200,6 +206,7 @@ export interface EditorLayoutV2Props {
|
|||||||
viewerToolbarRight?: ReactNode
|
viewerToolbarRight?: ReactNode
|
||||||
viewerContent: ReactNode
|
viewerContent: ReactNode
|
||||||
overlays?: ReactNode
|
overlays?: ReactNode
|
||||||
|
stageOverlay?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorLayoutV2({
|
export function EditorLayoutV2({
|
||||||
@@ -211,6 +218,7 @@ export function EditorLayoutV2({
|
|||||||
viewerToolbarRight,
|
viewerToolbarRight,
|
||||||
viewerContent,
|
viewerContent,
|
||||||
overlays,
|
overlays,
|
||||||
|
stageOverlay,
|
||||||
}: EditorLayoutV2Props) {
|
}: EditorLayoutV2Props) {
|
||||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
@@ -246,6 +254,7 @@ export function EditorLayoutV2({
|
|||||||
)}
|
)}
|
||||||
<RightColumn
|
<RightColumn
|
||||||
overlays={overlays}
|
overlays={overlays}
|
||||||
|
stageOverlay={stageOverlay}
|
||||||
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
|
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
|
||||||
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
|
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export interface EditorProps {
|
|||||||
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
|
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
|
||||||
viewerToolbarLeft?: ReactNode
|
viewerToolbarLeft?: ReactNode
|
||||||
viewerToolbarRight?: 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"
|
* 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
|
* affordance here so it reads as part of the inspector surface and shows
|
||||||
@@ -1077,6 +1083,7 @@ export default function Editor({
|
|||||||
sidebarTabs,
|
sidebarTabs,
|
||||||
viewerToolbarLeft,
|
viewerToolbarLeft,
|
||||||
viewerToolbarRight,
|
viewerToolbarRight,
|
||||||
|
stageOverlay,
|
||||||
inspectorFooter,
|
inspectorFooter,
|
||||||
projectId,
|
projectId,
|
||||||
onLoad,
|
onLoad,
|
||||||
@@ -1364,7 +1371,7 @@ export default function Editor({
|
|||||||
navbarSlot={navbarSlot}
|
navbarSlot={navbarSlot}
|
||||||
overlays={
|
overlays={
|
||||||
<>
|
<>
|
||||||
{!isCaptureMode && <FloatingLevelSelector />}
|
{!(isCaptureMode || stageOverlay) && <FloatingLevelSelector />}
|
||||||
{!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
|
{!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
@@ -1392,6 +1399,7 @@ export default function Editor({
|
|||||||
renderTabContent={renderTabContent}
|
renderTabContent={renderTabContent}
|
||||||
sidebarOverlay={sidebarOverlay}
|
sidebarOverlay={sidebarOverlay}
|
||||||
sidebarTabs={tabBarTabs}
|
sidebarTabs={tabBarTabs}
|
||||||
|
stageOverlay={stageOverlay}
|
||||||
viewerContent={viewerCanvas}
|
viewerContent={viewerCanvas}
|
||||||
viewerToolbarLeft={viewerToolbarLeft}
|
viewerToolbarLeft={viewerToolbarLeft}
|
||||||
viewerToolbarRight={viewerToolbarRight}
|
viewerToolbarRight={viewerToolbarRight}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter } from '@pascal-app/core'
|
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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useIsMobile } from '../../hooks/use-mobile'
|
import { useIsMobile } from '../../hooks/use-mobile'
|
||||||
import { triggerSFX } from '../../lib/sfx-bus'
|
import { triggerSFX } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor, {
|
||||||
|
type SnapshotCropMode,
|
||||||
|
type SnapshotStandardAspect,
|
||||||
|
} from '../../store/use-editor'
|
||||||
|
|
||||||
// Local crop-mode enum — distinct from `useEditor.captureMode` (which
|
// Local alias — distinct from `useEditor.captureMode` (which describes *why*
|
||||||
// describes *why* a capture is happening, e.g. `preset`). This one says
|
// a capture is happening, e.g. `preset`). This one says HOW the captured
|
||||||
// HOW the captured pixels are cropped: full-frame 16:9 (`standard`),
|
// pixels are cropped: full-frame 16:9 (`standard`), raw canvas viewport, or
|
||||||
// raw canvas viewport, or user-dragged area.
|
// user-dragged area. Hosts can preselect it via `captureMode.crop`.
|
||||||
type CropMode = 'standard' | 'viewport' | 'area'
|
type CropMode = SnapshotCropMode
|
||||||
type CaptureState = 'idle' | 'capturing' | 'saved'
|
type CaptureState = 'idle' | 'capturing' | 'saved'
|
||||||
|
|
||||||
interface DragPoint {
|
interface DragPoint {
|
||||||
@@ -24,12 +27,23 @@ interface Drag {
|
|||||||
end: DragPoint
|
end: DragPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Output presets for `standard` captures — long edge stays near 1920.
|
||||||
|
const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> = {
|
||||||
|
'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(
|
function getResolution(
|
||||||
mode: CropMode,
|
mode: CropMode,
|
||||||
overlayEl: HTMLDivElement | null,
|
overlayEl: HTMLDivElement | null,
|
||||||
drag: Drag | null,
|
drag: Drag | null,
|
||||||
|
standardAspect: StandardAspect,
|
||||||
): { w: number; h: number } | null {
|
): { w: number; h: number } | null {
|
||||||
if (mode === 'standard') return { w: 1920, h: 1080 }
|
if (mode === 'standard') return STANDARD_SIZES[standardAspect]
|
||||||
|
|
||||||
if (!overlayEl) return null
|
if (!overlayEl) return null
|
||||||
const rect = overlayEl.getBoundingClientRect()
|
const rect = overlayEl.getBoundingClientRect()
|
||||||
@@ -49,6 +63,41 @@ function getResolution(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rule-of-thirds guide rendered inside a framing surface. */
|
||||||
|
function ThirdsGrid() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: `
|
||||||
|
linear-gradient(to right, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
|
||||||
|
linear-gradient(to right, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px)),
|
||||||
|
linear-gradient(to bottom, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
|
||||||
|
linear-gradient(to bottom, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accented corner brackets on the framing rect. */
|
||||||
|
function CornerAccents() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="pointer-events-none absolute -top-0.5 -left-0.5 h-5 w-5 rounded-tl-md border-primary border-t-[2.5px] border-l-[2.5px]" />
|
||||||
|
<span className="pointer-events-none absolute -right-0.5 -bottom-0.5 h-5 w-5 rounded-br-md border-primary border-r-[2.5px] border-b-[2.5px]" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CropMode, string> = {
|
||||||
|
standard: 'Standard',
|
||||||
|
viewport: 'Viewport',
|
||||||
|
area: 'Area',
|
||||||
|
}
|
||||||
|
|
||||||
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
||||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||||
const captureMode = useEditor((s) => s.captureMode)
|
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
|
// a transparent background — the user picks framing but not the
|
||||||
// crop shape. Matches the unified preset-thumbnail capture flow.
|
// crop shape. Matches the unified preset-thumbnail capture flow.
|
||||||
const isPreset = captureMode.mode === 'preset'
|
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<CropMode>('standard')
|
const [mode, setMode] = useState<CropMode>('standard')
|
||||||
|
const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
|
||||||
|
const [aspectMenuOpen, setAspectMenuOpen] = useState(false)
|
||||||
const [drag, setDrag] = useState<Drag | null>(null)
|
const [drag, setDrag] = useState<Drag | null>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [captureState, setCaptureState] = useState<CaptureState>('idle')
|
const [captureState, setCaptureState] = useState<CaptureState>('idle')
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(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
|
// Dismiss on Esc
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,7 +148,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
// tweak the framing, but they don't have to draw the rect first.
|
// tweak the framing, but they don't have to draw the rect first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCaptureMode) return
|
if (!isCaptureMode) return
|
||||||
setMode(isPreset ? 'area' : 'standard')
|
setMode(isPreset ? 'area' : (requestedCrop ?? 'standard'))
|
||||||
|
setStandardAspect(requestedAspect ?? '16:9')
|
||||||
|
setAspectMenuOpen(false)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
setCaptureState('idle')
|
setCaptureState('idle')
|
||||||
if (isPreset && overlayRef.current) {
|
if (isPreset && overlayRef.current) {
|
||||||
@@ -97,7 +165,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
} else {
|
} else {
|
||||||
setDrag(null)
|
setDrag(null)
|
||||||
}
|
}
|
||||||
}, [isCaptureMode, isPreset])
|
}, [isCaptureMode, isPreset, requestedCrop, requestedAspect])
|
||||||
|
|
||||||
// Listen for snapshot saved to show feedback then exit
|
// Listen for snapshot saved to show feedback then exit
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -265,16 +333,36 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
projectId,
|
projectId,
|
||||||
captureMode: mode,
|
captureMode: mode,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
|
standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined,
|
||||||
// In preset mode, the ThumbnailGenerator should keep the alpha
|
// In preset mode, the ThumbnailGenerator should keep the alpha
|
||||||
// channel transparent so the saved preset thumbnail composes
|
// channel transparent so the saved preset thumbnail composes
|
||||||
// cleanly onto any palette background.
|
// cleanly onto any palette background.
|
||||||
transparent: isPreset,
|
transparent: isPreset,
|
||||||
})
|
})
|
||||||
}, [captureState, mode, drag, projectId, isPreset])
|
}, [captureState, mode, drag, projectId, isPreset, standardAspect])
|
||||||
|
|
||||||
if (!isCaptureMode) return null
|
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)
|
// Area selection rect (CSS px, relative to overlay)
|
||||||
const selectionStyle =
|
const selectionStyle =
|
||||||
@@ -294,6 +382,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
|
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
|
||||||
|
{/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
|
||||||
|
{standardFrame && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute rounded-md border-[1.5px] border-white/85"
|
||||||
|
style={{
|
||||||
|
...standardFrame,
|
||||||
|
boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThirdsGrid />
|
||||||
|
<CornerAccents />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Viewport mode: the whole canvas is the frame — thirds only */}
|
||||||
|
{mode === 'viewport' && <ThirdsGrid />}
|
||||||
|
|
||||||
{/* Area mode: dim layer + crosshair cursor + drag-to-select.
|
{/* Area mode: dim layer + crosshair cursor + drag-to-select.
|
||||||
*
|
*
|
||||||
* Preset mode reuses the same DOM but stays click-through: the
|
* 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. */}
|
has a pre-staged square, so we never show it there. */}
|
||||||
{!selectionStyle && !isPreset && (
|
{!selectionStyle && !isPreset && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
|
<span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
|
||||||
Drag the area you want to capture
|
Drag the area you want to capture
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +450,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
{/* Selection rect */}
|
{/* Selection rect */}
|
||||||
{selectionStyle && (
|
{selectionStyle && (
|
||||||
<div
|
<div
|
||||||
|
className="rounded-sm border-[1.5px] border-white/85"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: selectionStyle.left,
|
left: selectionStyle.left,
|
||||||
@@ -352,11 +458,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
width: selectionStyle.width,
|
width: selectionStyle.width,
|
||||||
height: selectionStyle.height,
|
height: selectionStyle.height,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
|
boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
|
||||||
border: '2px dashed rgba(255,255,255,0.85)',
|
background: 'rgba(255,255,255,0.03)',
|
||||||
background: 'rgba(255,255,255,0.04)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{hasSelection && <ThirdsGrid />}
|
||||||
|
<CornerAccents />
|
||||||
{/* Corner handles — preset mode locks the frame to the
|
{/* Corner handles — preset mode locks the frame to the
|
||||||
auto-staged centered square; the user adjusts the
|
auto-staged centered square; the user adjusts the
|
||||||
camera instead. */}
|
camera instead. */}
|
||||||
@@ -390,11 +497,33 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Top-center HUD — what the shot will be */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="pointer-events-none absolute top-4 left-1/2 flex -translate-x-1/2 gap-2">
|
||||||
|
<div className={HUD_CHIP_CLASS}>
|
||||||
|
<span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
|
||||||
|
Crop
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-white text-xs">
|
||||||
|
{isPreset ? 'Preset · square' : CROP_LABELS[mode]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={HUD_CHIP_CLASS}>
|
||||||
|
<span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
|
||||||
|
Format
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-white text-xs tabular-nums">
|
||||||
|
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top-right dismiss button (icon-only on mobile) */}
|
{/* Top-right dismiss button (icon-only on mobile) */}
|
||||||
<div className="pointer-events-auto absolute top-4 right-4">
|
<div className="pointer-events-auto absolute top-4 right-4">
|
||||||
<button
|
<button
|
||||||
aria-label="Close capture mode"
|
aria-label="Close capture mode"
|
||||||
className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 text-white/80 text-xs backdrop-blur-md transition-colors hover:bg-neutral-950 hover:text-white"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -403,104 +532,110 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom-center mode toolbar */}
|
{/* Subtle scrim so the bottom controls stay readable on bright scenes */}
|
||||||
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/45 via-black/15 to-transparent" />
|
||||||
{(() => {
|
|
||||||
// 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 : (
|
|
||||||
<>
|
|
||||||
<ModeButton
|
|
||||||
active={mode === 'standard'}
|
|
||||||
badge="16:9"
|
|
||||||
icon={<Monitor className="h-3.5 w-3.5" />}
|
|
||||||
label="Standard"
|
|
||||||
onClick={() => {
|
|
||||||
setMode('standard')
|
|
||||||
setDrag(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ModeButton
|
|
||||||
active={mode === 'viewport'}
|
|
||||||
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
|
||||||
label="Viewport"
|
|
||||||
onClick={() => {
|
|
||||||
setMode('viewport')
|
|
||||||
setDrag(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ModeButton
|
|
||||||
active={mode === 'area'}
|
|
||||||
icon={<Crop className="h-3.5 w-3.5" />}
|
|
||||||
label="Area"
|
|
||||||
onClick={() => setMode('area')}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolutionDisplay = (
|
{/* Bottom-center: crop switcher, caption + shutter */}
|
||||||
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
|
<div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
|
||||||
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
|
{!isPreset && (
|
||||||
</span>
|
<div className="pointer-events-auto relative flex items-center gap-1 rounded-full border border-white/10 bg-neutral-950/85 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
|
||||||
)
|
{/* Clicking Standard while it's active opens the aspect picker */}
|
||||||
|
<ModeButton
|
||||||
const captureButton = (
|
active={mode === 'standard'}
|
||||||
<button
|
badge={standardAspect}
|
||||||
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
|
icon={<Monitor className="h-3.5 w-3.5" />}
|
||||||
disabled={captureDisabled}
|
label={isMobile ? undefined : 'Standard'}
|
||||||
onClick={handleCapture}
|
onClick={() => {
|
||||||
type="button"
|
if (mode === 'standard') setAspectMenuOpen((v) => !v)
|
||||||
>
|
else setAspectMenuOpen(false)
|
||||||
{captureState === 'capturing' ? (
|
setMode('standard')
|
||||||
<>
|
setDrag(null)
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
}}
|
||||||
Capturing
|
/>
|
||||||
</>
|
{aspectMenuOpen && mode === 'standard' && (
|
||||||
) : captureState === 'saved' ? (
|
<div className="absolute bottom-[calc(100%+8px)] left-0 flex gap-1 rounded-full border border-white/10 bg-neutral-950/90 p-1.5 shadow-xl backdrop-blur-md">
|
||||||
<>
|
{(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
|
||||||
<Check className="h-3.5 w-3.5" />
|
<button
|
||||||
Saved
|
className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
|
||||||
</>
|
aspect === standardAspect
|
||||||
) : (
|
? 'bg-white/15 text-white ring-1 ring-white/20'
|
||||||
<>
|
: 'text-white/55 hover:text-white/90'
|
||||||
<Camera className="h-3.5 w-3.5" />
|
}`}
|
||||||
Capture
|
key={aspect}
|
||||||
</>
|
onClick={() => {
|
||||||
)}
|
setStandardAspect(aspect)
|
||||||
</button>
|
setAspectMenuOpen(false)
|
||||||
)
|
}}
|
||||||
|
type="button"
|
||||||
if (isMobile) {
|
>
|
||||||
return (
|
{aspect}
|
||||||
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
|
</button>
|
||||||
{modeButtons && (
|
))}
|
||||||
<div className="flex items-center justify-center gap-1">{modeButtons}</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
modeButtons
|
|
||||||
? 'flex items-center justify-center gap-2 border-white/10 border-t pt-2'
|
|
||||||
: 'flex items-center justify-center gap-2'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{resolutionDisplay}
|
|
||||||
{captureButton}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
<ModeButton
|
||||||
|
active={mode === 'viewport'}
|
||||||
|
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
||||||
|
label={isMobile ? undefined : 'Viewport'}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('viewport')
|
||||||
|
setDrag(null)
|
||||||
|
setAspectMenuOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
active={mode === 'area'}
|
||||||
|
icon={<Crop className="h-3.5 w-3.5" />}
|
||||||
|
label={isMobile ? undefined : 'Area'}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('area')
|
||||||
|
setAspectMenuOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isMobile && (
|
||||||
|
<span className="px-2 text-white/50 text-xs tabular-nums">
|
||||||
|
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
|
<span className="pointer-events-none max-w-90 rounded-lg border border-white/10 bg-neutral-950/85 px-3.5 py-1.5 text-center text-[11.5px] text-white/85 leading-relaxed backdrop-blur-md">
|
||||||
{modeButtons}
|
A <b className="font-semibold text-white">snapshot</b>
|
||||||
{modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
|
{' freezes this exact camera angle as a reusable reference for renders & videos.'}
|
||||||
{resolutionDisplay}
|
</span>
|
||||||
<div className="mx-1 h-4 w-px bg-white/10" />
|
)}
|
||||||
{captureButton}
|
|
||||||
</div>
|
<button
|
||||||
)
|
aria-label="Take snapshot"
|
||||||
})()}
|
className="group pointer-events-auto relative grid h-14 w-14 place-items-center rounded-full disabled:opacity-50"
|
||||||
|
disabled={captureDisabled}
|
||||||
|
onClick={handleCapture}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 rounded-full border-[3px] border-white shadow-lg" />
|
||||||
|
<span
|
||||||
|
className={`h-10 w-10 rounded-full transition-transform duration-100 ${
|
||||||
|
captureState === 'capturing'
|
||||||
|
? 'scale-[0.55] bg-primary'
|
||||||
|
: 'bg-white group-hover:scale-90 group-active:scale-75'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{captureState === 'capturing' && (
|
||||||
|
<Loader2 className="absolute h-4 w-4 animate-spin text-white" />
|
||||||
|
)}
|
||||||
|
{captureState === 'saved' && (
|
||||||
|
<Check className="absolute h-5 w-5 stroke-3 text-neutral-900" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="pointer-events-none font-mono text-[10.5px] text-white uppercase tracking-[0.12em] drop-shadow">
|
||||||
|
{captureState === 'capturing'
|
||||||
|
? 'Capturing…'
|
||||||
|
: captureState === 'saved'
|
||||||
|
? 'Saved'
|
||||||
|
: 'Take snapshot'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -515,7 +650,7 @@ function ModeButton({
|
|||||||
}: {
|
}: {
|
||||||
active: boolean
|
active: boolean
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label?: string
|
||||||
badge?: string
|
badge?: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
snapLevels: boolean,
|
snapLevels: boolean,
|
||||||
captureMode?: 'standard' | 'viewport' | 'area',
|
captureMode?: 'standard' | 'viewport' | 'area',
|
||||||
cropRegion?: { x: number; y: number; width: number; height: number },
|
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 (isGenerating.current) return
|
||||||
if (!onThumbnailCaptureRef.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)
|
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
|
||||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||||
} else {
|
} else {
|
||||||
// Standard: center-crop to 1920×1080 aspect ratio
|
// Standard: center-crop to the requested aspect (default 1920×1080)
|
||||||
const srcAspect = width / height
|
const srcAspect = width / height
|
||||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
const dstAspect = standardW / standardH
|
||||||
let sx = 0,
|
let sx = 0,
|
||||||
sy = 0,
|
sy = 0,
|
||||||
sWidth = width,
|
sWidth = width,
|
||||||
@@ -356,8 +359,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
sHeight = Math.round(width / dstAspect)
|
sHeight = Math.round(width / dstAspect)
|
||||||
sy = Math.round((height - sHeight) / 2)
|
sy = Math.round((height - sHeight) / 2)
|
||||||
}
|
}
|
||||||
outW = THUMBNAIL_WIDTH
|
outW = standardW
|
||||||
outH = THUMBNAIL_HEIGHT
|
outH = standardH
|
||||||
const offscreen = new OffscreenCanvas(outW, outH)
|
const offscreen = new OffscreenCanvas(outW, outH)
|
||||||
offscreen
|
offscreen
|
||||||
.getContext('2d')!
|
.getContext('2d')!
|
||||||
@@ -414,7 +417,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const srcAspect = width / height
|
const srcAspect = width / height
|
||||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
const dstAspect = standardW / standardH
|
||||||
let sx = 0,
|
let sx = 0,
|
||||||
sy = 0,
|
sy = 0,
|
||||||
sWidth = width,
|
sWidth = width,
|
||||||
@@ -426,8 +429,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
sHeight = Math.round(width / dstAspect)
|
sHeight = Math.round(width / dstAspect)
|
||||||
sy = Math.round((height - sHeight) / 2)
|
sy = Math.round((height - sHeight) / 2)
|
||||||
}
|
}
|
||||||
outW = THUMBNAIL_WIDTH
|
outW = standardW
|
||||||
outH = THUMBNAIL_HEIGHT
|
outH = standardH
|
||||||
const offscreen = document.createElement('canvas')
|
const offscreen = document.createElement('canvas')
|
||||||
offscreen.width = outW
|
offscreen.width = outW
|
||||||
offscreen.height = outH
|
offscreen.height = outH
|
||||||
@@ -468,6 +471,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
const handleGenerateThumbnail = async (event: {
|
const handleGenerateThumbnail = async (event: {
|
||||||
captureMode?: 'standard' | 'viewport' | 'area'
|
captureMode?: 'standard' | 'viewport' | 'area'
|
||||||
cropRegion?: { x: number; y: number; width: number; height: number }
|
cropRegion?: { x: number; y: number; width: number; height: number }
|
||||||
|
standardSize?: { w: number; h: number }
|
||||||
snapLevels?: boolean
|
snapLevels?: boolean
|
||||||
// `transparent` is informational here — the render pipeline already
|
// `transparent` is informational here — the render pipeline already
|
||||||
// captures with alpha (see `setClearAlpha(0)` above) — the flag is
|
// 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.
|
// background bits) can branch on it without touching the emitter.
|
||||||
transparent?: boolean
|
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)
|
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||||
|
|||||||
@@ -365,7 +365,10 @@ export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
|||||||
export { default as useAudio } from './store/use-audio'
|
export { default as useAudio } from './store/use-audio'
|
||||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||||
export type {
|
export type {
|
||||||
|
CaptureMode,
|
||||||
FloorplanSelectionTool,
|
FloorplanSelectionTool,
|
||||||
|
SnapshotCropMode,
|
||||||
|
SnapshotStandardAspect,
|
||||||
SplitOrientation,
|
SplitOrientation,
|
||||||
Tool,
|
Tool,
|
||||||
ToolDefaults,
|
ToolDefaults,
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export type WorkspaceMode = 'edit' | 'studio'
|
|||||||
// — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
|
// — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
|
||||||
// applies those constraints. Keeping it a discriminated union lets us
|
// applies those constraints. Keeping it a discriminated union lets us
|
||||||
// add future modes without surfacing the choice to end users.
|
// 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 =
|
export type CaptureMode =
|
||||||
| { mode: 'idle' }
|
| { mode: 'idle' }
|
||||||
| { mode: 'standard' }
|
| { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect }
|
||||||
| {
|
| {
|
||||||
mode: 'preset'
|
mode: 'preset'
|
||||||
isolated: AnyNodeId[]
|
isolated: AnyNodeId[]
|
||||||
|
|||||||
Reference in New Issue
Block a user