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:
Wassim SAMAD
2026-07-06 13:09:56 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 22af31dea8
commit 9b5e229770
7 changed files with 299 additions and 123 deletions
+5
View File
@@ -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 {/* Bottom-center: crop switcher, caption + shutter */}
// the transparent-background output — hide the per-shape <div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
// mode buttons so the user has nothing to second-guess. {!isPreset && (
const modeButtons = isPreset ? null : ( <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 <ModeButton
active={mode === 'standard'} active={mode === 'standard'}
badge="16:9" badge={standardAspect}
icon={<Monitor className="h-3.5 w-3.5" />} icon={<Monitor className="h-3.5 w-3.5" />}
label="Standard" label={isMobile ? undefined : 'Standard'}
onClick={() => { onClick={() => {
if (mode === 'standard') setAspectMenuOpen((v) => !v)
else setAspectMenuOpen(false)
setMode('standard') setMode('standard')
setDrag(null) setDrag(null)
}} }}
/> />
{aspectMenuOpen && mode === 'standard' && (
<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) => (
<button
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'
}`}
key={aspect}
onClick={() => {
setStandardAspect(aspect)
setAspectMenuOpen(false)
}}
type="button"
>
{aspect}
</button>
))}
</div>
)}
<ModeButton <ModeButton
active={mode === 'viewport'} active={mode === 'viewport'}
icon={<Maximize2 className="h-3.5 w-3.5" />} icon={<Maximize2 className="h-3.5 w-3.5" />}
label="Viewport" label={isMobile ? undefined : 'Viewport'}
onClick={() => { onClick={() => {
setMode('viewport') setMode('viewport')
setDrag(null) setDrag(null)
setAspectMenuOpen(false)
}} }}
/> />
<ModeButton <ModeButton
active={mode === 'area'} active={mode === 'area'}
icon={<Crop className="h-3.5 w-3.5" />} icon={<Crop className="h-3.5 w-3.5" />}
label="Area" label={isMobile ? undefined : 'Area'}
onClick={() => setMode('area')} onClick={() => {
setMode('area')
setAspectMenuOpen(false)
}}
/> />
</> {isMobile && (
) <span className="px-2 text-white/50 text-xs tabular-nums">
const resolutionDisplay = (
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
{resolution ? `${resolution.w} × ${resolution.h}` : '—'} {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
</span> </span>
) )}
</div>
)}
{!isMobile && (
<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">
A <b className="font-semibold text-white">snapshot</b>
{' freezes this exact camera angle as a reusable reference for renders & videos.'}
</span>
)}
const captureButton = (
<button <button
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" 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} disabled={captureDisabled}
onClick={handleCapture} onClick={handleCapture}
type="button" type="button"
> >
{captureState === 'capturing' ? ( <span className="absolute inset-0 rounded-full border-[3px] border-white shadow-lg" />
<> <span
<Loader2 className="h-3.5 w-3.5 animate-spin" /> className={`h-10 w-10 rounded-full transition-transform duration-100 ${
Capturing captureState === 'capturing'
</> ? 'scale-[0.55] bg-primary'
) : captureState === 'saved' ? ( : 'bg-white group-hover:scale-90 group-active:scale-75'
<> }`}
<Check className="h-3.5 w-3.5" /> />
Saved {captureState === 'capturing' && (
</> <Loader2 className="absolute h-4 w-4 animate-spin text-white" />
) : ( )}
<> {captureState === 'saved' && (
<Camera className="h-3.5 w-3.5" /> <Check className="absolute h-5 w-5 stroke-3 text-neutral-900" />
Capture
</>
)} )}
</button> </button>
) <span className="pointer-events-none font-mono text-[10.5px] text-white uppercase tracking-[0.12em] drop-shadow">
{captureState === 'capturing'
if (isMobile) { ? 'Capturing…'
return ( : captureState === 'saved'
<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"> ? 'Saved'
{modeButtons && ( : 'Take snapshot'}
<div className="flex items-center justify-center gap-1">{modeButtons}</div> </span>
)}
<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>
)
}
return (
<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">
{modeButtons}
{modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
{resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" />
{captureButton}
</div>
)
})()}
</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)
+3
View File
@@ -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,
+8 -1
View File
@@ -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[]