Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -342,15 +344,19 @@ 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',
headers: { 'Content-Type': 'image/webp' },
body: blob,
}).catch(() => {})
},
[meta.id],
[meta.id, meta.name, meta.projectId],
)

return (
Expand Down
133 changes: 133 additions & 0 deletions apps/editor/lib/snapshot-download.test.ts
Original file line number Diff line number Diff line change
@@ -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<SnapshotSavedEvent | undefined> = []
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<SnapshotSavedEvent | undefined> = []
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)
}
}
})
42 changes: 42 additions & 0 deletions apps/editor/lib/snapshot-download.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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,
})
}
21 changes: 21 additions & 0 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
35 changes: 32 additions & 3 deletions packages/editor/src/components/editor/snapshot-capture-overlay.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -224,6 +224,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle')
const [captureError, setCaptureError] = useState<string | null>(null)
const pendingCapture = useRef<string | null>(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)
Expand Down Expand Up @@ -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
Expand All @@ -270,17 +274,30 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
if (!isCaptureMode) return
let timer: ReturnType<typeof setTimeout> | 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(() => {
setCaptureMode(false)
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])
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -886,6 +907,14 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
</span>
))}

{captureError && (
<p
className="max-w-sm rounded-lg bg-neutral-950/90 px-3 py-2 text-center text-red-300 text-xs"
role="alert"
>
{captureError}
</p>
)}
<button
aria-label={isPreset ? 'Capture' : 'Take snapshot'}
className="group pointer-events-auto relative grid h-14 w-14 place-items-center rounded-full disabled:opacity-50"
Expand Down
12 changes: 12 additions & 0 deletions packages/editor/src/components/editor/snapshot-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ test('manual capture feedback ignores background requests and other projects', (
expect(isOverlaySnapshotSave({ ...saved, projectId: 'project' }, 'project')).toBe(true)
expect(isOverlaySnapshotSave({ ...saved, projectId: 'other' }, 'project')).toBe(false)
expect(isOverlaySnapshotSave({ ...saved, requestId: 'background' }, 'project')).toBe(false)
expect(isOverlaySnapshotSave({ ...saved, requestId: 'shutter' }, 'project', 'shutter')).toBe(true)
expect(isOverlaySnapshotSave({ ...saved, requestId: 'old-shutter' }, 'project', 'shutter')).toBe(
false,
)
expect(isOverlaySnapshotSave(saved, 'project', 'shutter')).toBe(false)
expect(
isOverlaySnapshotSave(
{ ...saved, projectId: 'other', requestId: 'shutter' },
'project',
'shutter',
),
).toBe(false)
})

describe('capture scene restoration', () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/editor/src/components/editor/snapshot-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
} from '@pascal-app/core'
import { MathUtils, type PerspectiveCamera } from 'three'

export function isOverlaySnapshotSave(event: SnapshotSavedEvent | undefined, projectId: string) {
return !event?.requestId && (!event?.projectId || event.projectId === projectId)
export function isOverlaySnapshotSave(
event: SnapshotSavedEvent | undefined,
projectId: string,
requestId?: string,
) {
return event?.requestId === requestId && (!event?.projectId || event.projectId === projectId)
}

export function createSnapshotQueue() {
Expand Down