diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx
index bf0d66c4..74ac18bb 100644
--- a/apps/editor/app/viewer/[id]/page.tsx
+++ b/apps/editor/app/viewer/[id]/page.tsx
@@ -1,16 +1,18 @@
'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core'
-import { Viewer, useViewer } from '@pascal-app/viewer'
+import { useViewer, Viewer } from '@pascal-app/viewer'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
+import {
+ getProjectModelPublic,
+ incrementProjectViews,
+} from '@/features/community/lib/projects/actions'
+import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ViewerCameraControls } from './viewer-camera-controls'
+import { ViewerGuestCTA } from './viewer-guest-cta'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
-import { ThumbnailGenerator } from './thumbnail-generator'
-import { ViewerGuestCTA } from './viewer-guest-cta'
-import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
-import type { ProjectOwner } from '@/features/community/lib/projects/types'
export default function ViewerPage() {
const params = useParams()
@@ -106,12 +108,16 @@ export default function ViewerPage() {
return (
-
+
-
)
diff --git a/apps/editor/app/viewer/[id]/thumbnail-generator.tsx b/apps/editor/app/viewer/[id]/thumbnail-generator.tsx
deleted file mode 100644
index 4dec9b9f..00000000
--- a/apps/editor/app/viewer/[id]/thumbnail-generator.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-'use client'
-
-import { emitter } from '@pascal-app/core'
-import { useThree } from '@react-three/fiber'
-import { useEffect, useRef } from 'react'
-import * as THREE from 'three'
-import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
-import { useProjectStore } from '@/features/community/lib/projects/store'
-
-const THUMBNAIL_WIDTH = 1920
-const THUMBNAIL_HEIGHT = 1080
-
-interface ThumbnailGeneratorProps {
- projectId?: string
-}
-
-export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
- const gl = useThree((state) => state.gl)
- const scene = useThree((state) => state.scene)
- const camera = useThree((state) => state.camera)
- const isGenerating = useRef(false)
-
- // Use prop projectId (from URL)
- const fallbackProjectId = propProjectId
-
- useEffect(() => {
- const handleGenerateThumbnail = async (event: { projectId: string }) => {
- if (isGenerating.current) {
- console.log('βΈοΈ Thumbnail generation already in progress')
- return
- }
-
- // Prioritize prop projectId over event projectId (URL has priority over session)
- const projectId = fallbackProjectId || event.projectId
-
- if (!projectId) {
- console.error('β No project ID provided')
- return
- }
-
- isGenerating.current = true
- console.log('πΈ Generating thumbnail for project:', projectId)
-
- try {
- // Save current renderer state
- const currentSize = gl.getSize(new THREE.Vector2())
- const currentPixelRatio = gl.getPixelRatio()
-
- // Temporarily resize renderer to thumbnail size
- gl.setPixelRatio(1)
- gl.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
-
- // Update camera aspect ratio if it's a perspective camera
- if (camera instanceof THREE.PerspectiveCamera) {
- camera.aspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
- camera.updateProjectionMatrix()
- }
-
- // Render the scene
- gl.render(scene, camera)
-
- // Wait a frame to ensure render is complete
- await new Promise((resolve) => requestAnimationFrame(resolve))
-
- // Capture canvas as blob
- const canvas = gl.domElement
- canvas.toBlob(async (blob) => {
- if (blob) {
- // Upload to Supabase Storage
- console.log('βοΈ Uploading thumbnail to storage...')
- const result = await uploadProjectThumbnail(projectId, blob)
-
- if (result.success) {
- useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
- } else {
- console.error('β Failed to upload thumbnail:', result.error)
- }
- } else {
- console.error('β Failed to create blob from canvas')
- }
-
- // Restore renderer size and camera
- gl.setPixelRatio(currentPixelRatio)
- gl.setSize(currentSize.x, currentSize.y)
-
- if (camera instanceof THREE.PerspectiveCamera) {
- camera.aspect = currentSize.x / currentSize.y
- camera.updateProjectionMatrix()
- }
-
- isGenerating.current = false
- }, 'image/png')
- } catch (error) {
- console.error('β Failed to generate thumbnail:', error)
-
- // Make sure to restore size even on error
- const currentSize = gl.getSize(new THREE.Vector2())
- const currentPixelRatio = gl.getPixelRatio()
- gl.setPixelRatio(currentPixelRatio)
- gl.setSize(currentSize.x, currentSize.y)
-
- if (camera instanceof THREE.PerspectiveCamera) {
- camera.aspect = currentSize.x / currentSize.y
- camera.updateProjectionMatrix()
- }
-
- isGenerating.current = false
- }
- }
-
- emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
-
- return () => {
- emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
- }
- }, [gl, scene, camera, fallbackProjectId])
-
- return null
-}
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index 391b47a4..40edca0c 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -2,7 +2,6 @@
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { Viewer } from '@pascal-app/viewer'
-import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks'
import { useProjectScene } from '@/features/community/lib/models/hooks'
@@ -23,6 +22,7 @@ import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { Grid } from './grid'
import { SelectionManager } from './selection-manager'
+import { ThumbnailGenerator } from './thumbnail-generator'
// Load default scene initially (will be replaced when project loads)
useScene.getState().loadScene()
diff --git a/apps/editor/components/editor/thumbnail-generator.tsx b/apps/editor/components/editor/thumbnail-generator.tsx
new file mode 100644
index 00000000..02a84d6b
--- /dev/null
+++ b/apps/editor/components/editor/thumbnail-generator.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { emitter, useScene } from '@pascal-app/core'
+import { useThree } from '@react-three/fiber'
+import { useCallback, useEffect, useRef } from 'react'
+import * as THREE from 'three'
+import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
+import { useProjectStore } from '@/features/community/lib/projects/store'
+
+const THUMBNAIL_WIDTH = 1920
+const THUMBNAIL_HEIGHT = 1080
+const AUTO_SAVE_DELAY = 10_000
+
+interface ThumbnailGeneratorProps {
+ projectId?: string
+}
+
+export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
+ const gl = useThree((state) => state.gl)
+ const scene = useThree((state) => state.scene)
+ const isGenerating = useRef(false)
+ const debounceTimerRef = useRef | null>(null)
+ const pendingAutoRef = useRef(false)
+
+ const generate = useCallback(async (projectId: string) => {
+ if (isGenerating.current) {
+ console.log('βΈοΈ Thumbnail generation already in progress')
+ return
+ }
+
+ isGenerating.current = true
+ console.log('πΈ Generating thumbnail for project:', projectId)
+
+ try {
+ const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
+
+ // Check if the site node has a saved camera, otherwise use default isometric position
+ const nodes = useScene.getState().nodes
+ const siteNode = Object.values(nodes).find((n) => n.type === 'site')
+
+ if (siteNode?.camera) {
+ const { position, target } = siteNode.camera
+ thumbnailCamera.position.set(position[0], position[1], position[2])
+ thumbnailCamera.lookAt(target[0], target[1], target[2])
+ } else {
+ thumbnailCamera.position.set(8, 8, 8)
+ thumbnailCamera.lookAt(0, 0, 0)
+ }
+
+ // Match camera aspect to current canvas so the render looks correct
+ const { width, height } = gl.domElement
+ thumbnailCamera.aspect = width / height
+ thumbnailCamera.updateProjectionMatrix()
+
+ // Render with thumbnail camera β main canvas is never resized
+ gl.render(scene, thumbnailCamera)
+
+ // Center-crop the canvas to the thumbnail aspect ratio, then scale β avoids deformation
+ const srcAspect = width / height
+ const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
+ let sx = 0, sy = 0, sWidth = width, sHeight = height
+ if (srcAspect > dstAspect) {
+ sWidth = Math.round(height * dstAspect)
+ sx = Math.round((width - sWidth) / 2)
+ } else if (srcAspect < dstAspect) {
+ sHeight = Math.round(width / dstAspect)
+ sy = Math.round((height - sHeight) / 2)
+ }
+
+ const offscreen = document.createElement('canvas')
+ offscreen.width = THUMBNAIL_WIDTH
+ offscreen.height = THUMBNAIL_HEIGHT
+ const ctx = offscreen.getContext('2d')!
+ ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
+
+ offscreen.toBlob(async (blob) => {
+ if (blob) {
+ console.log('βοΈ Uploading thumbnail to storage...')
+ const result = await uploadProjectThumbnail(projectId, blob)
+
+ if (result.success) {
+ useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
+ } else {
+ console.error('β Failed to upload thumbnail:', result.error)
+ }
+ } else {
+ console.error('β Failed to create blob from canvas')
+ }
+
+ isGenerating.current = false
+ }, 'image/png')
+ } catch (error) {
+ console.error('β Failed to generate thumbnail:', error)
+ isGenerating.current = false
+ }
+ }, [gl, scene])
+
+ // Manual trigger via emitter
+ useEffect(() => {
+ const handleGenerateThumbnail = async (event: { projectId: string }) => {
+ const projectId = propProjectId || event.projectId
+ if (!projectId) {
+ console.error('β No project ID provided')
+ return
+ }
+ await generate(projectId)
+ }
+
+ emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
+ return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
+ }, [generate, propProjectId])
+
+ // Auto-trigger: debounced on scene changes, deferred if tab is hidden
+ useEffect(() => {
+ if (!propProjectId) return
+
+ const triggerNow = () => generate(propProjectId)
+
+ const scheduleOrDefer = () => {
+ if (document.visibilityState === 'visible') {
+ triggerNow()
+ } else {
+ // Tab is hidden β remember to fire when the user comes back
+ pendingAutoRef.current = true
+ }
+ }
+
+ const onSceneChange = () => {
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
+ debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
+ }
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible' && pendingAutoRef.current) {
+ pendingAutoRef.current = false
+ triggerNow()
+ }
+ }
+
+ // Subscribe to node changes β any structural edit resets the timer
+ const unsubscribe = useScene.subscribe((state, prevState) => {
+ if (state.nodes !== prevState.nodes) onSceneChange()
+ })
+
+ document.addEventListener('visibilitychange', onVisibilityChange)
+
+ return () => {
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
+ unsubscribe()
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ }
+ }, [propProjectId, generate])
+
+ return null
+}