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 +}