diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 1622d61c43..fc74429b30 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -9,6 +9,7 @@ import { ItemsPanel, type SceneGraph, type SidebarTab, + type SnapshotCameraData, } from '@pascal-app/editor' import { PascalWebXRButton } from '@webxr/plugin/pascal-editor' import { Hammer, Layers, Package, Palette, Settings } from 'lucide-react' @@ -23,6 +24,7 @@ import { type PersistedSceneGraph, sceneGraphSignature, } from '@/lib/scene-signature' +import { downloadSnapshot } from '@/lib/snapshot-download' import { useThumbnailAutosave } from '@/lib/use-thumbnail-autosave' import { BuildTab } from './build-tab' import { EditorNavbar } from './editor-navbar' @@ -342,7 +344,11 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { }, [meta.id]) const handleThumb = useCallback( - async (blob: Blob, cameraData: { requestId?: string }) => { + async (blob: Blob, cameraData: SnapshotCameraData) => { + if (cameraData.captureMode) { + await downloadSnapshot(meta.projectId ?? 'default', meta.name, blob, cameraData) + return + } if (cameraData.requestId) return await fetch(`/api/scenes/${meta.id}/thumbnail`, { method: 'POST', @@ -350,7 +356,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { body: blob, }).catch(() => {}) }, - [meta.id], + [meta.id, meta.name, meta.projectId], ) return ( diff --git a/apps/editor/lib/snapshot-download.test.ts b/apps/editor/lib/snapshot-download.test.ts new file mode 100644 index 0000000000..5d3c5cf661 --- /dev/null +++ b/apps/editor/lib/snapshot-download.test.ts @@ -0,0 +1,133 @@ +import { expect, spyOn, test } from 'bun:test' +import { emitter, type SnapshotSavedEvent } from '@pascal-app/core' +import { downloadSnapshot } from './snapshot-download' + +const camera = { + requestId: 'shutter-1', + position: [1, 2, 3] as [number, number, number], + target: null, + captureMode: 'standard' as const, + resolution: { w: 1920, h: 1080 }, +} + +test('a snapshot downloads its captured blob before acknowledging the exact shutter request', async () => { + const saved: Array = [] + const onSaved = (event: SnapshotSavedEvent | undefined) => saved.push(event) + emitter.on('snapshot:saved', onSaved) + const blob = new Blob(['captured pixels'], { type: 'image/webp' }) + let complete!: (url: string) => void + try { + const pending = downloadSnapshot( + 'project', + 'My house / kitchen', + blob, + camera, + (image, name) => { + expect(image).toBe(blob) + expect(name).toMatch(/^My-house-kitchen-snapshot-.*\.webp$/) + return new Promise((resolve) => { + complete = resolve + }) + }, + ) + expect(saved).toEqual([]) + complete('blob:download') + await pending + expect(saved).toEqual([ + { + id: 'shutter-1', + requestId: 'shutter-1', + projectId: 'project', + url: 'blob:download', + width: 1920, + height: 1080, + }, + ]) + } finally { + emitter.off('snapshot:saved', onSaved) + } +}) + +test('a failed download rejects without reporting that the snapshot was saved', async () => { + const saved: Array = [] + const onSaved = (event: SnapshotSavedEvent | undefined) => saved.push(event) + emitter.on('snapshot:saved', onSaved) + try { + await expect( + downloadSnapshot('project', 'House', new Blob(), camera, () => { + throw new Error('Download unavailable') + }), + ).rejects.toThrow('Download unavailable') + expect(saved).toEqual([]) + } finally { + emitter.off('snapshot:saved', onSaved) + } +}) + +test('PNG captures download with the correct extension and portrait dimensions', async () => { + const blob = new Blob(['portrait pixels'], { type: 'image/png' }) + await downloadSnapshot( + 'project', + '', + blob, + { ...camera, resolution: { w: 1080, h: 1920 } }, + (_, name) => { + expect(name).toMatch(/^Pascal-snapshot-.*\.png$/) + return 'blob:portrait' + }, + ) +}) + +test('the browser download URL stays usable for 60 seconds, then is revoked', async () => { + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + const previousTimer = Object.getOwnPropertyDescriptor(globalThis, 'setTimeout') + const timers: Array<{ run: () => void; delay: number }> = [] + let clicked = false + let removed = false + const anchor = { + href: '', + download: '', + click: () => { + clicked = true + }, + remove: () => { + removed = true + }, + } + const created = spyOn(URL, 'createObjectURL').mockReturnValue('blob:browser-download') + const revoked = spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => anchor, + body: { appendChild: () => {} }, + }, + }) + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (run: () => void, delay: number) => { + timers.push({ run, delay }) + return 0 + }, + }) + try { + await downloadSnapshot('project', 'Room', new Blob(['pixels'], { type: 'image/webp' }), camera) + expect(clicked).toBe(true) + expect(removed).toBe(true) + expect(anchor.href).toBe('blob:browser-download') + expect(revoked).not.toHaveBeenCalled() + expect(timers.map(({ delay }) => delay)).toEqual([60_000]) + timers[0].run() + expect(revoked).toHaveBeenCalledWith('blob:browser-download') + } finally { + created.mockRestore() + revoked.mockRestore() + for (const [key, descriptor] of [ + ['document', previousDocument], + ['setTimeout', previousTimer], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + } +}) diff --git a/apps/editor/lib/snapshot-download.ts b/apps/editor/lib/snapshot-download.ts new file mode 100644 index 0000000000..aa7566f1db --- /dev/null +++ b/apps/editor/lib/snapshot-download.ts @@ -0,0 +1,42 @@ +import { emitter } from '@pascal-app/core' +import type { SnapshotCameraData } from '@pascal-app/editor' + +function downloadImage(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + try { + anchor.click() + return url + } finally { + anchor.remove() + setTimeout(() => URL.revokeObjectURL(url), 60_000) + } +} + +/** + * A user snapshot downloads locally; background thumbnails use their own upload path. + * The saved event's URL is a temporary download URL, valid for 60 seconds, not stored media. + */ +export async function downloadSnapshot( + projectId: string, + projectName: string, + blob: Blob, + camera: SnapshotCameraData, + download: (blob: Blob, filename: string) => string | Promise = downloadImage, +) { + const name = projectName.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'Pascal' + const extension = blob.type === 'image/png' ? 'png' : blob.type === 'image/jpeg' ? 'jpg' : 'webp' + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const url = await download(blob, `${name}-snapshot-${timestamp}.${extension}`) + emitter.emit('snapshot:saved', { + projectId, + requestId: camera.requestId, + id: camera.requestId ?? crypto.randomUUID(), + url, + width: camera.resolution?.w ?? 0, + height: camera.resolution?.h ?? 0, + }) +} diff --git a/packages/editor/README.md b/packages/editor/README.md index f7290955b7..85c19b7f17 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -34,6 +34,27 @@ export function ProjectPage({ projectId }: { projectId: string }) { `apps/editor/components/scene-loader.tsx` is a complete host: navbar, sidebar tabs, toolbars and persistence. +## Snapshot completion + +Hosts provide `onThumbnailCapture(blob, cameraData)` to store or download captures. +The snapshot shutter now supplies a `cameraData.requestId`. Process manual captures +(`cameraData.captureMode` is set) before any filter that skips background requests +with a `requestId`. After the image has been stored or its download started, emit +`snapshot:saved` from `@pascal-app/core`'s `emitter`, echoing that exact `requestId` +and the editor's `projectId`, with the image `id`, `url`, `width`, and `height`. +The overlay only completes for its own shutter request. Hosts that omit the ID or +skip every capture carrying an ID must update their handlers to avoid a stuck shutter. + +Return a promise from the callback and reject it when saving or downloading fails. +The capture pipeline emits the correlated `snapshot:capture-failed` event; the +overlay displays the error and enables retry. Ephemeral captures return directly +to their caller and do not pass through host storage. + +The standalone app downloads manual snapshots without replacing the project +thumbnail; background thumbnail captures continue to upload. Its saved event URL +is a temporary blob URL valid for 60 seconds. Consumers needing a persistent image +must save their own copy rather than retain that URL. + ## Host props | Prop | What it does | diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index af6dc3d38c..fa7fb776ef 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -1,6 +1,6 @@ 'use client' -import { emitter, type SnapshotSavedEvent } from '@pascal-app/core' +import { emitter, type SnapshotCaptureFailedEvent, type SnapshotSavedEvent } from '@pascal-app/core' import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer' import { Check, @@ -224,6 +224,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const [drag, setDrag] = useState(null) const [isDragging, setIsDragging] = useState(false) const [captureState, setCaptureState] = useState('idle') + const [captureError, setCaptureError] = useState(null) + const pendingCapture = useRef(null) const overlayRef = useRef(null) // Overlay size drives the computed standard-frame rect + resolution HUD. const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null) @@ -251,6 +253,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { setAspectMenuOpen(false) setIsDragging(false) setCaptureState('idle') + setCaptureError(null) + pendingCapture.current = null if (isPreset && overlayRef.current) { const rect = overlayRef.current.getBoundingClientRect() const side = Math.min(rect.width, rect.height) * 0.75 @@ -270,7 +274,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { if (!isCaptureMode) return let timer: ReturnType | undefined const handler = (event: SnapshotSavedEvent | undefined) => { - if (!isOverlaySnapshotSave(event, projectId)) return + if ( + !pendingCapture.current || + !isOverlaySnapshotSave(event, projectId, pendingCapture.current) + ) + return + pendingCapture.current = null setCaptureState('saved') clearTimeout(timer) timer = setTimeout(() => { @@ -278,9 +287,17 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { setCaptureState('idle') }, 1500) } + const onFailure = (event: SnapshotCaptureFailedEvent) => { + if (event.requestId !== pendingCapture.current) return + pendingCapture.current = null + setCaptureError(event.error) + setCaptureState('idle') + } + emitter.on('snapshot:capture-failed', onFailure) emitter.on('snapshot:saved', handler) return () => { emitter.off('snapshot:saved', handler) + emitter.off('snapshot:capture-failed', onFailure) clearTimeout(timer) } }, [isCaptureMode, projectId, setCaptureMode]) @@ -423,7 +440,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { ) const handleCapture = useCallback(() => { - if (captureState !== 'idle') return + if (captureState !== 'idle' || pendingCapture.current) return let cropRegion: { x: number; y: number; width: number; height: number } | undefined if (mode === 'area' && drag && overlayRef.current) { @@ -440,9 +457,13 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { } } + const requestId = crypto.randomUUID() + pendingCapture.current = requestId + setCaptureError(null) setCaptureState('capturing') triggerSFX('sfx:snapshot-capture') emitter.emit('camera-controls:generate-thumbnail', { + requestId, projectId, captureMode: mode, cropRegion, @@ -886,6 +907,14 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { ))} + {captureError && ( +

+ {captureError} +

+ )}