From c205be9902b285a67cd8a4d3a807717e753a8f51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:31:32 +0000 Subject: [PATCH 1/5] Initial plan From 6a0eed51cff266cc7737d69be41535ff57b63959 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:38:43 +0000 Subject: [PATCH 2/5] feat: add high quality canvas export --- src/components/SoundscapePreview.tsx | 14 ++ src/pages/GenerateJSON.tsx | 189 +++++++++++++++++---------- src/store.ts | 30 +++++ src/utils/canvasExporter.ts | 157 ++++++++++++++++++++++ 4 files changed, 321 insertions(+), 69 deletions(-) create mode 100644 src/utils/canvasExporter.ts diff --git a/src/components/SoundscapePreview.tsx b/src/components/SoundscapePreview.tsx index 2e06ddf..3c7a73a 100644 --- a/src/components/SoundscapePreview.tsx +++ b/src/components/SoundscapePreview.tsx @@ -1,6 +1,7 @@ import type { ElementRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Canvas } from "@react-three/fiber"; +import type { RootState } from "@react-three/fiber"; import { Grid, OrbitControls } from "@react-three/drei"; import type { Mesh } from "three"; import * as THREE from "three"; @@ -15,6 +16,7 @@ import { useFullscreenPreviewStore, useMeshStore, useOrbitStore, + usePreviewExportStore, useThemeStore, } from "../store"; @@ -69,6 +71,8 @@ export function SoundscapePreview({ const orbitRef = useRef | null>(null); const setMesh = useMeshStore((s) => s.setMesh); const setOrbit = useOrbitStore((s) => s.setOrbit); + const setPreviewExport = usePreviewExportStore((s) => s.setPreviewExport); + const clearPreviewExport = usePreviewExportStore((s) => s.clearPreviewExport); const toggleFullscreenPreview = useFullscreenPreviewStore( (s) => s.toggleFullscreenPreview, ); @@ -104,6 +108,15 @@ export function SoundscapePreview({ } }, [setOrbit]); + useEffect(() => clearPreviewExport, [clearPreviewExport]); + + const handleCanvasCreated = useCallback( + ({ gl, scene, camera }: RootState) => { + setPreviewExport(gl.domElement, gl, scene, camera); + }, + [setPreviewExport], + ); + const formatTime = (seconds: number) => { if (!Number.isFinite(seconds) || seconds < 0) { return "00:00"; @@ -206,6 +219,7 @@ export function SoundscapePreview({ )} (null); const [isExporting, setIsExporting] = useState(false); const [isExportingGltf, setIsExportingGltf] = useState(false); + const [isExportingImage, setIsExportingImage] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [generatedNumChunks, setGeneratedNumChunks] = @@ -215,6 +217,10 @@ export default function GenerateJSON() { const setFullscreenPreviewOpen = useFullscreenPreviewStore( (s) => s.setFullscreenPreviewOpen, ); + const previewCanvas = usePreviewExportStore((s) => s.canvas); + const previewRenderer = usePreviewExportStore((s) => s.renderer); + const previewScene = usePreviewExportStore((s) => s.scene); + const previewCamera = usePreviewExportStore((s) => s.camera); const levaStore = useCreateStore(); const [showLevaNumchunksPopup, setShowLevaNumchunksPopup] = useState(false); const [pendingNumChunks, setPendingNumChunks] = useState(null); @@ -295,39 +301,6 @@ export default function GenerateJSON() { [generatedNumChunks, selectedFile], ); - useControls( - "Soundscape Controls", - { - Actions: folder({ - swapGradientSides: button(() => { - const currentLeftTopColor = getControls("leftTopColor"); - const currentLeftBottomColor = getControls("leftBottomColor"); - const currentRightTopColor = getControls("rightTopColor"); - const currentRightBottomColor = getControls("rightBottomColor"); - - setControls({ - leftTopColor: currentRightTopColor, - leftBottomColor: currentRightBottomColor, - rightTopColor: currentLeftTopColor, - rightBottomColor: currentLeftBottomColor, - }); - blurActiveElement(); - }), - exportPackage: button(() => { - if (isExporting) return; - void handleExport(); - blurActiveElement(); - }), - export3DModel: button(() => { - if (isExportingGltf) return; - void handleExportGltf(); - blurActiveElement(); - }), - }), - }, - { store: levaStore }, - ); - const regenerateSoundscape = useCallback( async (file: File, chunkCount: number) => { setError(null); @@ -540,6 +513,44 @@ export default function GenerateJSON() { const appliedNumChunks = analysis ? generatedNumChunks : numChunks; const zSpacing = soundscapeLength / Math.max(1, appliedNumChunks); + // scale raw data to reasonable height and also produce vectors + const { soundLinesVectors, scaledLists } = useMemo(() => { + if (!analysis) + return { + soundLinesVectors: [] as THREE.Vector3[][], + scaledLists: [] as number[][], + }; + // optionally amplify values; larger amplifyFactor should yield a taller model + let tempAmplified = null; + if (!reverseOutput) { + tempAmplified = analysis + .map((row) => row.map((y) => Math.pow(y, amplifyFactor))) + .reverse(); + } else { + tempAmplified = analysis.map((row) => + row.map((y) => Math.pow(y, amplifyFactor)), + ); + } + const amplified = tempAmplified; + // instead of normalizing to a constant height, simply multiply by an overall + // constant so that increasing amplifyFactor makes the mesh visibly larger + const baseHeight = 3; // adjust if the mesh is too tall/short + const scaleFactor = amplifyFactor * baseHeight; + const zOffset = ((analysis.length || 0) - 1) * zSpacing * 0.5; + const scaled = amplified.map((row) => row.map((y) => y * scaleFactor)); + const vectors = scaled.map((row, t) => + row.map( + (y, x) => + new THREE.Vector3(x - row.length / 2, y, t * zSpacing - zOffset), + ), + ); + if (!reverseOutput) { + return { soundLinesVectors: vectors.reverse(), scaledLists: scaled }; + } else { + return { soundLinesVectors: vectors, scaledLists: scaled }; + } + }, [amplifyFactor, analysis, reverseOutput, zSpacing]); + const handleExport = async () => { if (!selectedFile || !analysis || duration === null) return; @@ -585,43 +596,83 @@ export default function GenerateJSON() { } }; - // scale raw data to reasonable height and also produce vectors - const { soundLinesVectors, scaledLists } = useMemo(() => { - if (!analysis) - return { - soundLinesVectors: [] as THREE.Vector3[][], - scaledLists: [] as number[][], - }; - // optionally amplify values; larger amplifyFactor should yield a taller model - let tempAmplified = null; - if (!reverseOutput) { - tempAmplified = analysis - .map((row) => row.map((y) => Math.pow(y, amplifyFactor))) - .reverse(); - } else { - tempAmplified = analysis.map((row) => - row.map((y) => Math.pow(y, amplifyFactor)), - ); + const handleExportImage = async () => { + if ( + !selectedFile || + !previewCanvas || + !previewRenderer || + !previewScene || + !previewCamera + ) { + return; } - const amplified = tempAmplified; - // instead of normalizing to a constant height, simply multiply by an overall - // constant so that increasing amplifyFactor makes the mesh visibly larger - const baseHeight = 3; // adjust if the mesh is too tall/short - const scaleFactor = amplifyFactor * baseHeight; - const zOffset = ((analysis.length || 0) - 1) * zSpacing * 0.5; - const scaled = amplified.map((row) => row.map((y) => y * scaleFactor)); - const vectors = scaled.map((row, t) => - row.map( - (y, x) => - new THREE.Vector3(x - row.length / 2, y, t * zSpacing - zOffset), - ), - ); - if (!reverseOutput) { - return { soundLinesVectors: vectors.reverse(), scaledLists: scaled }; - } else { - return { soundLinesVectors: vectors, scaledLists: scaled }; + + setError(null); + setIsExportingImage(true); + + try { + await exportHighQualityCanvas({ + sourceCanvas: previewCanvas, + renderer: previewRenderer, + scene: previewScene, + camera: previewCamera, + fileName: `${selectedFile.name.replace(/\.[^/.]+$/, "") || "soundscape"}.png`, + }); + } catch (e) { + console.error(e); + setError("Failed to export image."); + } finally { + setIsExportingImage(false); } - }, [amplifyFactor, analysis, reverseOutput, zSpacing]); + }; + + useControls( + "Soundscape Controls", + () => ({ + Actions: folder({ + swapGradientSides: button(() => { + const currentLeftTopColor = getControls("leftTopColor"); + const currentLeftBottomColor = getControls("leftBottomColor"); + const currentRightTopColor = getControls("rightTopColor"); + const currentRightBottomColor = getControls("rightBottomColor"); + + setControls({ + leftTopColor: currentRightTopColor, + leftBottomColor: currentRightBottomColor, + rightTopColor: currentLeftTopColor, + rightBottomColor: currentLeftBottomColor, + }); + blurActiveElement(); + }), + exportPackage: button(() => { + if (isExporting) return; + void handleExport(); + blurActiveElement(); + }), + export3DModel: button(() => { + if (isExportingGltf) return; + void handleExportGltf(); + blurActiveElement(); + }), + downloadHighQualityImage: button(() => { + if (isExportingImage) return; + void handleExportImage(); + blurActiveElement(); + }), + }), + }), + { store: levaStore }, + [ + getControls, + handleExport, + handleExportGltf, + handleExportImage, + isExporting, + isExportingGltf, + isExportingImage, + setControls, + ], + ); return (
diff --git a/src/store.ts b/src/store.ts index 892e765..e7bfe85 100644 --- a/src/store.ts +++ b/src/store.ts @@ -92,6 +92,36 @@ export const useOrbitStore = create((set) => ({ setOrbit: (cameraPos, target) => set({ cameraPos, target }), })); +interface PreviewExportState { + canvas: HTMLCanvasElement | null + renderer: THREE.WebGLRenderer | null + scene: THREE.Scene | null + camera: THREE.Camera | null + setPreviewExport: ( + canvas: HTMLCanvasElement, + renderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.Camera, + ) => void + clearPreviewExport: () => void +} + +export const usePreviewExportStore = create((set) => ({ + canvas: null, + renderer: null, + scene: null, + camera: null, + setPreviewExport: (canvas, renderer, scene, camera) => + set({ canvas, renderer, scene, camera }), + clearPreviewExport: () => + set({ + canvas: null, + renderer: null, + scene: null, + camera: null, + }), +})) + interface AudioState { currentTime: number; duration: number; diff --git a/src/utils/canvasExporter.ts b/src/utils/canvasExporter.ts new file mode 100644 index 0000000..639e566 --- /dev/null +++ b/src/utils/canvasExporter.ts @@ -0,0 +1,157 @@ +import * as THREE from 'three' + +const SCREEN_DPI = 96 + +export interface ExportHighQualityCanvasInput { + sourceCanvas: HTMLCanvasElement + renderer: THREE.WebGLRenderer + scene: THREE.Scene + camera: THREE.Camera + fileName?: string + targetDpi?: number +} + +export async function exportHighQualityCanvas( + input: ExportHighQualityCanvasInput, +) { + const { + sourceCanvas, + renderer: sourceRenderer, + scene, + camera, + fileName = 'soundscape-print.png', + targetDpi = 300, + } = input + + const sourceWidth = sourceCanvas.clientWidth || sourceCanvas.width + const sourceHeight = sourceCanvas.clientHeight || sourceCanvas.height + + if (!sourceWidth || !sourceHeight) { + throw new Error('The soundscape preview is not ready to export.') + } + + const scaleFactor = targetDpi / SCREEN_DPI + const targetWidth = Math.max(1, Math.round(sourceWidth * scaleFactor)) + const targetHeight = Math.max(1, Math.round(sourceHeight * scaleFactor)) + const offScreenCanvas = document.createElement('canvas') + const offScreenRenderer = new THREE.WebGLRenderer({ + canvas: offScreenCanvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }) + const exportCamera = camera.clone() + + try { + offScreenRenderer.setPixelRatio(1) + offScreenRenderer.setSize(targetWidth, targetHeight, false) + offScreenRenderer.outputColorSpace = sourceRenderer.outputColorSpace + offScreenRenderer.toneMapping = sourceRenderer.toneMapping + offScreenRenderer.toneMappingExposure = sourceRenderer.toneMappingExposure + offScreenRenderer.shadowMap.enabled = sourceRenderer.shadowMap.enabled + offScreenRenderer.shadowMap.type = sourceRenderer.shadowMap.type + offScreenRenderer.setClearColor( + sourceRenderer.getClearColor(new THREE.Color()), + sourceRenderer.getClearAlpha(), + ) + + updateCameraProjection(exportCamera, targetWidth, targetHeight) + offScreenRenderer.render(scene, exportCamera) + + const outputCanvas = composeCanvasWithBackground( + offScreenCanvas, + sourceCanvas.parentElement, + ) + const blob = await canvasToBlob(outputCanvas) + downloadBlob(blob, fileName) + + if (outputCanvas !== offScreenCanvas) { + outputCanvas.width = 0 + outputCanvas.height = 0 + } + } finally { + offScreenRenderer.dispose() + offScreenRenderer.forceContextLoss() + offScreenCanvas.width = 0 + offScreenCanvas.height = 0 + } +} + +function updateCameraProjection( + camera: THREE.Camera, + width: number, + height: number, +) { + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = width / height + camera.updateProjectionMatrix() + return + } + + if (camera instanceof THREE.OrthographicCamera) { + const aspect = width / height + const currentHeight = camera.top - camera.bottom + const halfWidth = (currentHeight * aspect) / 2 + camera.left = -halfWidth + camera.right = halfWidth + camera.updateProjectionMatrix() + } +} + +function composeCanvasWithBackground( + renderedCanvas: HTMLCanvasElement, + container: HTMLElement | null, +) { + const backgroundColor = container + ? window.getComputedStyle(container).backgroundColor + : '' + + if ( + !backgroundColor || + backgroundColor === 'transparent' || + backgroundColor === 'rgba(0, 0, 0, 0)' + ) { + return renderedCanvas + } + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = renderedCanvas.width + outputCanvas.height = renderedCanvas.height + + const context = outputCanvas.getContext('2d') + if (!context) { + throw new Error('Could not get 2D context for canvas export.') + } + + context.imageSmoothingEnabled = true + context.imageSmoothingQuality = 'high' + context.fillStyle = backgroundColor + context.fillRect(0, 0, outputCanvas.width, outputCanvas.height) + context.drawImage(renderedCanvas, 0, 0) + + return outputCanvas +} + +function canvasToBlob(canvas: HTMLCanvasElement) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Canvas to Blob conversion failed.')) + return + } + + resolve(blob) + }, 'image/png', 1) + }) +} + +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} From d6e8b3f4ae3effab49dc58a5429f62c655a4aa56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:40:48 +0000 Subject: [PATCH 3/5] fix: polish image export download handling --- src/components/SoundscapePreview.tsx | 4 +++- src/utils/canvasExporter.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SoundscapePreview.tsx b/src/components/SoundscapePreview.tsx index 3c7a73a..51dc6b8 100644 --- a/src/components/SoundscapePreview.tsx +++ b/src/components/SoundscapePreview.tsx @@ -108,7 +108,9 @@ export function SoundscapePreview({ } }, [setOrbit]); - useEffect(() => clearPreviewExport, [clearPreviewExport]); + useEffect(() => { + return () => clearPreviewExport(); + }, [clearPreviewExport]); const handleCanvasCreated = useCallback( ({ gl, scene, camera }: RootState) => { diff --git a/src/utils/canvasExporter.ts b/src/utils/canvasExporter.ts index 639e566..03aa739 100644 --- a/src/utils/canvasExporter.ts +++ b/src/utils/canvasExporter.ts @@ -153,5 +153,5 @@ function downloadBlob(blob: Blob, fileName: string) { document.body.appendChild(link) link.click() document.body.removeChild(link) - URL.revokeObjectURL(url) + window.setTimeout(() => URL.revokeObjectURL(url), 0) } From a419b2b9df57d0c692a1c76137b6c24cffc83632 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:42:45 +0000 Subject: [PATCH 4/5] fix: harden canvas export cleanup --- src/components/SoundscapePreview.tsx | 7 ++++--- src/utils/canvasExporter.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/SoundscapePreview.tsx b/src/components/SoundscapePreview.tsx index 51dc6b8..83e3333 100644 --- a/src/components/SoundscapePreview.tsx +++ b/src/components/SoundscapePreview.tsx @@ -72,7 +72,6 @@ export function SoundscapePreview({ const setMesh = useMeshStore((s) => s.setMesh); const setOrbit = useOrbitStore((s) => s.setOrbit); const setPreviewExport = usePreviewExportStore((s) => s.setPreviewExport); - const clearPreviewExport = usePreviewExportStore((s) => s.clearPreviewExport); const toggleFullscreenPreview = useFullscreenPreviewStore( (s) => s.toggleFullscreenPreview, ); @@ -109,8 +108,10 @@ export function SoundscapePreview({ }, [setOrbit]); useEffect(() => { - return () => clearPreviewExport(); - }, [clearPreviewExport]); + return () => { + usePreviewExportStore.getState().clearPreviewExport(); + }; + }, []); const handleCanvasCreated = useCallback( ({ gl, scene, camera }: RootState) => { diff --git a/src/utils/canvasExporter.ts b/src/utils/canvasExporter.ts index 03aa739..cf398ae 100644 --- a/src/utils/canvasExporter.ts +++ b/src/utils/canvasExporter.ts @@ -153,5 +153,5 @@ function downloadBlob(blob: Blob, fileName: string) { document.body.appendChild(link) link.click() document.body.removeChild(link) - window.setTimeout(() => URL.revokeObjectURL(url), 0) + window.setTimeout(() => URL.revokeObjectURL(url), 100) } From 22bad59d787aac6a97a21ee27b57c7a50160e9e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:44:25 +0000 Subject: [PATCH 5/5] fix: extend export URL cleanup delay --- src/utils/canvasExporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/canvasExporter.ts b/src/utils/canvasExporter.ts index cf398ae..079b3f7 100644 --- a/src/utils/canvasExporter.ts +++ b/src/utils/canvasExporter.ts @@ -153,5 +153,5 @@ function downloadBlob(blob: Blob, fileName: string) { document.body.appendChild(link) link.click() document.body.removeChild(link) - window.setTimeout(() => URL.revokeObjectURL(url), 100) + window.setTimeout(() => URL.revokeObjectURL(url), 1000) }