automated thumbnail + position enhancement
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
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 { useParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
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 { ViewerCameraControls } from './viewer-camera-controls'
|
||||||
|
import { ViewerGuestCTA } from './viewer-guest-cta'
|
||||||
import { ViewerOverlay } from './viewer-overlay'
|
import { ViewerOverlay } from './viewer-overlay'
|
||||||
import { ViewerZoneSystem } from './viewer-zone-system'
|
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() {
|
export default function ViewerPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -106,12 +108,16 @@ export default function ViewerPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
<ViewerOverlay projectName={projectName} owner={owner} canShowScans={canShowScans} canShowGuides={canShowGuides} />
|
<ViewerOverlay
|
||||||
|
projectName={projectName}
|
||||||
|
owner={owner}
|
||||||
|
canShowScans={canShowScans}
|
||||||
|
canShowGuides={canShowGuides}
|
||||||
|
/>
|
||||||
<ViewerGuestCTA />
|
<ViewerGuestCTA />
|
||||||
<Viewer>
|
<Viewer>
|
||||||
<ViewerCameraControls />
|
<ViewerCameraControls />
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
<ThumbnailGenerator projectId={projectId || undefined} />
|
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { Viewer } from '@pascal-app/viewer'
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator'
|
|
||||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||||
import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks'
|
import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks'
|
||||||
import { useProjectScene } from '@/features/community/lib/models/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 { ExportManager } from './export-manager'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
|
|
||||||
// Load default scene initially (will be replaced when project loads)
|
// Load default scene initially (will be replaced when project loads)
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setTimeout> | 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user