Skip to content
Merged
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
13 changes: 11 additions & 2 deletions packages/editor/src/components/editor/snapshot-capture-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { emitter } from '@pascal-app/core'
import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer'
import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useIsMobile } from '../../hooks/use-mobile'
Expand Down Expand Up @@ -37,6 +38,14 @@ const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> =
}
type StandardAspect = SnapshotStandardAspect

function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
const maxEdge = Math.max(width, height)
if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }

const scale = SNAPSHOT_MAX_EDGE / maxEdge
return { w: Math.round(width * scale), h: Math.round(height * scale) }
}

function getResolution(
mode: CropMode,
overlayEl: HTMLDivElement | null,
Expand All @@ -50,14 +59,14 @@ function getResolution(
const dpr = Math.min(window.devicePixelRatio, 1.5)

if (mode === 'viewport') {
return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
return clampSnapshotSize(Math.round(rect.width * dpr), Math.round(rect.height * dpr))
}

if (mode === 'area' && drag) {
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
if (w < 4 || h < 4) return null
return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
return clampSnapshotSize(Math.round(w * dpr), Math.round(h * dpr))
}

return null
Expand Down
27 changes: 19 additions & 8 deletions packages/editor/src/components/editor/thumbnail-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createSnapshotPipeline,
GRID_LAYER,
heroCameraPose,
SNAPSHOT_MAX_EDGE,
SNAPSHOT_MIME,
SNAPSHOT_QUALITY,
type SnapshotPipeline,
Expand Down Expand Up @@ -35,6 +36,14 @@ interface ThumbnailGeneratorProps {
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}

function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
const maxEdge = Math.max(width, height)
if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }

const scale = SNAPSHOT_MAX_EDGE / maxEdge
return { w: Math.round(width * scale), h: Math.round(height * scale) }
}

export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
Expand Down Expand Up @@ -236,12 +245,13 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
let outH: number

if (captureMode === 'viewport') {
outW = width
outH = height
;({ w: outW, h: outH } = clampSnapshotSize(width, height))
const offscreen = document.createElement('canvas')
offscreen.width = outW
offscreen.height = outH
offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
const ctx = offscreen.getContext('2d')!
if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
Expand All @@ -252,14 +262,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
} else if (captureMode === 'area' && cropRegion) {
const sx = Math.round(cropRegion.x * width)
const sy = Math.round(cropRegion.y * height)
outW = Math.round(cropRegion.width * width)
outH = Math.round(cropRegion.height * height)
const sourceW = Math.round(cropRegion.width * width)
const sourceH = Math.round(cropRegion.height * height)
;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
const offscreen = document.createElement('canvas')
offscreen.width = outW
offscreen.height = outH
offscreen
.getContext('2d')!
.drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
const ctx = offscreen.getContext('2d')!
if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export {
} from './lib/scene-themes'
export {
createSnapshotPipeline,
SNAPSHOT_MAX_EDGE,
SNAPSHOT_MIME,
SNAPSHOT_QUALITY,
type SnapshotCaptureMode,
Expand Down
26 changes: 20 additions & 6 deletions packages/viewer/src/lib/snapshot-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export const THUMBNAIL_HEIGHT = 1080
*/
export const SNAPSHOT_MIME = 'image/webp'
export const SNAPSHOT_QUALITY = 0.9
// Retina canvases make viewport/area captures multi-MB; 2048 keeps them near the 1920 presets.
export const SNAPSHOT_MAX_EDGE = 2048

function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
const maxEdge = Math.max(width, height)
if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }

const scale = SNAPSHOT_MAX_EDGE / maxEdge
return { w: Math.round(width * scale), h: Math.round(height * scale) }
}

export type SnapshotCaptureMode = 'standard' | 'viewport' | 'area'

Expand Down Expand Up @@ -334,18 +344,22 @@ export async function createSnapshotPipeline({
let blob: Blob

if (captureMode === 'viewport') {
outW = captureWidth
outH = captureHeight
;({ w: outW, h: outH } = clampSnapshotSize(captureWidth, captureHeight))
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
const ctx = offscreen.getContext('2d')!
if (outW !== captureWidth || outH !== captureHeight) ctx.imageSmoothingQuality = 'high'
ctx.drawImage(srcCanvas, 0, 0, captureWidth, captureHeight, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY })
} else if (captureMode === 'area' && cropRegion) {
const sx = Math.round(cropRegion.x * captureWidth)
const sy = Math.round(cropRegion.y * captureHeight)
outW = Math.round(cropRegion.width * captureWidth)
outH = Math.round(cropRegion.height * captureHeight)
const sourceW = Math.round(cropRegion.width * captureWidth)
const sourceH = Math.round(cropRegion.height * captureHeight)
;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
const ctx = offscreen.getContext('2d')!
if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
ctx.drawImage(srcCanvas, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY })
} else {
// Standard: center-crop to the requested aspect (default 1920×1080)
Expand Down
Loading