From b9a4642d5960765b8f672f845b373b65f27a6d13 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 12 Aug 2026 09:57:39 +0200 Subject: [PATCH] fix(editor): clamp viewport/area snapshot captures to 2048px long edge Viewport and area crop modes captured at the raw canvas backing-store resolution, so retina/5K displays produced multi-MB WebP snapshots that blow past upload transport limits. Clamp the output long edge to 2048 (matching the 1920-class standard presets) in the snapshot pipeline, the non-pipeline fallback, and the capture HUD resolution chip. Co-Authored-By: Claude Fable 5 --- .../editor/snapshot-capture-overlay.tsx | 13 +++++++-- .../components/editor/thumbnail-generator.tsx | 27 +++++++++++++------ packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/snapshot-pipeline.ts | 26 +++++++++++++----- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index dac3f2ef3a..5327f9271a 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -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' @@ -37,6 +38,14 @@ const STANDARD_SIZES: Record = } 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, @@ -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 diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index e70a3f1218..05f3a99fa2 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -6,6 +6,7 @@ import { createSnapshotPipeline, GRID_LAYER, heroCameraPose, + SNAPSHOT_MAX_EDGE, SNAPSHOT_MIME, SNAPSHOT_QUALITY, type SnapshotPipeline, @@ -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) @@ -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((resolve, reject) => offscreen.toBlob( (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))), @@ -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((resolve, reject) => offscreen.toBlob( (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))), diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index a0818828ca..c083b25379 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -138,6 +138,7 @@ export { } from './lib/scene-themes' export { createSnapshotPipeline, + SNAPSHOT_MAX_EDGE, SNAPSHOT_MIME, SNAPSHOT_QUALITY, type SnapshotCaptureMode, diff --git a/packages/viewer/src/lib/snapshot-pipeline.ts b/packages/viewer/src/lib/snapshot-pipeline.ts index ffe7deaec1..e43fe95c57 100644 --- a/packages/viewer/src/lib/snapshot-pipeline.ts +++ b/packages/viewer/src/lib/snapshot-pipeline.ts @@ -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' @@ -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)