From 7ce9574f3cf2ff628302d6a82994d33bbc27a8da Mon Sep 17 00:00:00 2001 From: Alexandre Angelim Date: Thu, 22 Oct 2026 20:41:33 -0400 Subject: [PATCH 1/2] editor: download snapshots and complete capture feedback --- apps/editor/components/scene-loader.tsx | 10 ++- apps/editor/lib/snapshot-download.test.ts | 79 +++++++++++++++++++ apps/editor/lib/snapshot-download.ts | 39 +++++++++ .../editor/snapshot-capture-overlay.tsx | 35 +++++++- .../editor/snapshot-capture.test.ts | 12 +++ .../src/components/editor/snapshot-capture.ts | 8 +- 6 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 apps/editor/lib/snapshot-download.test.ts create mode 100644 apps/editor/lib/snapshot-download.ts 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..c4c8536b4a --- /dev/null +++ b/apps/editor/lib/snapshot-download.test.ts @@ -0,0 +1,79 @@ +import { expect, 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' + }, + ) +}) diff --git a/apps/editor/lib/snapshot-download.ts b/apps/editor/lib/snapshot-download.ts new file mode 100644 index 0000000000..5e38e068d3 --- /dev/null +++ b/apps/editor/lib/snapshot-download.ts @@ -0,0 +1,39 @@ +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), 0) + } +} + +/** A user snapshot downloads locally; background thumbnails use their own upload path. */ +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/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} +

+ )}