Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
24.9 kB
import { startTransition, useCallback, useEffect, useRef, useState } from 'react'
import { DialRoot, useDialKitController } from 'dialkit'
import './App.css'
import { AholoSplatPreview } from './components/AholoSplatPreview'
import { SplatPreview, type CameraSnapshot } from './components/SplatPreview'
import { estimateFocalLengthFromFile, type FocalEstimate } from './lib/focal'
import { EMBED_SNIPPET } from './lib/embed'
import { decodeImageBitmap, readImageInfo } from './lib/image'
import { readSharpViewerMeta, writeSharpViewerMeta, type SharpViewerMeta } from '@ml-sharp-web/ply-metadata'
import { DEFAULT_MAX_GAUSSIANS, DEFAULT_OPACITY_THRESHOLD, DEFAULT_WEB_MODEL_URL } from './lib/sharpConstants'
import { SharpWebGPUModel } from './models/sharp/SharpWebGPUModel'
import type { WorkerStatusMessage } from './workers/messages'
const DEFAULT_BG_COLOR = '#101014'
const DEFAULT_FOV = 60
const DEFAULT_AUTO_ROTATE = false
const DEFAULT_MAX_SCREEN_SIZE = 2048
const DEFAULT_SPLAT_POSITION: [number, number, number] = [0, 0, 0]
// SHARP's raw output is mirrored and back-to-front, so the model-correct
// orientation is: flip X, flip Y, then 180° about the vertical (Y) axis.
// Transforms compose scale-then-rotate, so this is flip [X,Y] + rotate Y 180°.
const DEFAULT_SPLAT_ROTATION: [number, number, number] = [0, 180, 0]
const DEFAULT_SPLAT_FLIP: [boolean, boolean, boolean] = [true, true, false]
type RendererChoice = 'mkkellogg' | 'aholo'
interface SelectedImage {
file: File
previewUrl: string
width: number
height: number
focalEstimate: FocalEstimate
}
interface GenerationResult {
previewPlyUrl: string
downloadPlyUrl: string
downloadName: string
selectedGaussians: number
totalGaussians: number
fileSizeBytes: number
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
function sourceLabel(source: FocalEstimate['source']): string {
switch (source) {
case 'exif-35mm':
return 'EXIF 35mm-equivalent'
case 'exif-mm-approx':
return 'EXIF mm (approx. normalized to 35mm)'
case 'default-30mm':
return 'Default 30mm estimate'
default:
return source
}
}
function toOutputName(fileName: string): string {
const dot = fileName.lastIndexOf('.')
const stem = dot > 0 ? fileName.slice(0, dot) : fileName
return `${stem}.ply`
}
function App() {
const sharpModelRef = useRef<SharpWebGPUModel | null>(null)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const modelInputRef = useRef<HTMLInputElement | null>(null)
const actionsRef = useRef<Record<string, () => void>>({})
const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null)
const [modelFile, setModelFile] = useState<File | null>(null)
const [modelFileUrl, setModelFileUrl] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false)
const [statusText, setStatusText] = useState<string>('Load the model to begin.')
const [workerStage, setWorkerStage] = useState<WorkerStatusMessage['stage']>('idle')
const [workerProgress, setWorkerProgress] = useState<number | undefined>(undefined)
const [errorText, setErrorText] = useState<string | null>(null)
const [result, setResult] = useState<GenerationResult | null>(null)
const [plyBytes, setPlyBytes] = useState<Uint8Array | null>(null)
const [generationKey, setGenerationKey] = useState(0)
const [modelLoadState, setModelLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
const [loadElapsedSec, setLoadElapsedSec] = useState(0)
const loadedModelUrlRef = useRef<string | null>(null)
const [bakedMeta, setBakedMeta] = useState<SharpViewerMeta | null>(null)
const [saveStatus, setSaveStatus] = useState<string | undefined>(undefined)
const cameraSnapshotRef = useRef<CameraSnapshot | null>(null)
// Position and rotation are no longer user-editable — they hold the fixed
// SHARP orientation (or whatever a loaded PLY baked in). Flip stays editable,
// toggled by the Flip X/Y/Z action buttons.
const [splatPosition, setSplatPosition] = useState<[number, number, number]>(DEFAULT_SPLAT_POSITION)
const [splatRotation, setSplatRotation] = useState<[number, number, number]>(DEFAULT_SPLAT_ROTATION)
const [splatFlip, setSplatFlip] = useState<[boolean, boolean, boolean]>(DEFAULT_SPLAT_FLIP)
const runAction = useCallback((action: string) => {
actionsRef.current[action]?.()
}, [])
const effectiveModelUrl = modelFileUrl ?? DEFAULT_WEB_MODEL_URL
const modelLoaded = modelLoadState === 'loaded' && loadedModelUrlRef.current === effectiveModelUrl
// Once the model is loaded, the load/upload buttons are replaced by a static
// "loaded" indicator so it can't be loaded twice. Typed as a plain record so
// the branch difference doesn't collapse the config's inferred value types.
const modelSection: Record<string, { type: 'action'; label: string }> = modelLoaded
? {
loaded: { type: 'action', label: '✓ Model loaded' },
resetModel: { type: 'action', label: 'Reset model' },
}
: {
loadModel: { type: 'action', label: 'Load model' },
uploadModel: { type: 'action', label: 'Upload .onnx' },
resetModel: { type: 'action', label: 'Reset model' },
}
// A single panel whose controls are grouped into labelled folders that follow
// the workflow: Model → Image → Generation → Export → Viewer. The viewer
// folder starts collapsed (`_collapsed`); DialKit only supports collapsing
// nested folders, not sibling panels, which is why everything lives under one
// controller. Folder nesting means control/action paths are dot-prefixed
// (e.g. `image.focal`, `generation.generate`).
const controls = useDialKitController(
'SHARP',
{
model: modelSection,
image: {
uploadImage: { type: 'action', label: 'Upload image' },
focal: [0, 0, 8000, 1],
resetFocal: { type: 'action', label: 'Reset focal to EXIF' },
},
generation: {
opacity: [DEFAULT_OPACITY_THRESHOLD, 0, 1, 0.01],
maxGaussians: [DEFAULT_MAX_GAUSSIANS, 1000, 10_000_000, 1000],
generate: { type: 'action', label: 'Generate splat' },
},
export: {
download: { type: 'action', label: 'Download .ply' },
copyEmbed: { type: 'action', label: 'Copy embed' },
},
viewer: {
_collapsed: true,
renderer: { type: 'select', options: ['mkkellogg', 'aholo'], default: 'mkkellogg' },
bg: { type: 'color', default: DEFAULT_BG_COLOR },
fov: [DEFAULT_FOV, 20, 120, 1],
maxScreenSize: [DEFAULT_MAX_SCREEN_SIZE, 256, 4096, 1],
autoRotate: DEFAULT_AUTO_ROTATE as boolean,
flipX: { type: 'action', label: 'Flip X' },
flipY: { type: 'action', label: 'Flip Y' },
flipZ: { type: 'action', label: 'Flip Z' },
resetFlip: { type: 'action', label: 'Reset flip' },
saveDefaults: { type: 'action', label: 'Save as defaults' },
},
},
{ onAction: runAction },
)
const renderer = controls.values.viewer.renderer as RendererChoice
const focalPx = controls.values.image.focal
useEffect(() => {
return () => {
const model = sharpModelRef.current
sharpModelRef.current = null
if (model) void model.dispose()
}
}, [])
useEffect(() => {
if (!modelFile) {
setModelFileUrl((previous) => {
if (previous) URL.revokeObjectURL(previous)
return null
})
return
}
const url = URL.createObjectURL(modelFile)
setModelFileUrl((previous) => {
if (previous) URL.revokeObjectURL(previous)
return url
})
return () => {
URL.revokeObjectURL(url)
}
}, [modelFile])
useEffect(() => {
return () => {
if (selectedImage) URL.revokeObjectURL(selectedImage.previewUrl)
if (result) {
URL.revokeObjectURL(result.previewPlyUrl)
if (result.downloadPlyUrl !== result.previewPlyUrl) {
URL.revokeObjectURL(result.downloadPlyUrl)
}
}
}
}, [selectedImage, result])
useEffect(() => {
if (loadedModelUrlRef.current && loadedModelUrlRef.current !== effectiveModelUrl) {
const model = sharpModelRef.current
sharpModelRef.current = null
if (model) void model.dispose()
loadedModelUrlRef.current = null
setModelLoadState('idle')
setStatusText('Model URL changed — load the model again to proceed.')
}
}, [effectiveModelUrl])
useEffect(() => {
if (modelLoadState !== 'loading') {
setLoadElapsedSec(0)
return
}
const startedAt = Date.now()
setLoadElapsedSec(0)
const interval = window.setInterval(() => {
setLoadElapsedSec(Math.floor((Date.now() - startedAt) / 1000))
}, 1000)
return () => window.clearInterval(interval)
}, [modelLoadState])
// DialKit action buttons expose no per-button hook, so tag the primary
// (Generate) and the "model loaded" indicator by label and re-tag whenever
// DialKit re-renders the panel, letting CSS style them distinctly.
useEffect(() => {
const tag = () => {
for (const el of document.querySelectorAll<HTMLButtonElement>('.dialkit-button')) {
const text = el.textContent?.trim() ?? ''
el.classList.toggle('dialkit-button--primary', text === 'Generate splat')
el.classList.toggle('dialkit-button--ok', text.startsWith('✓'))
}
}
tag()
const observer = new MutationObserver(tag)
observer.observe(document.body, { childList: true, subtree: true })
return () => observer.disconnect()
}, [])
const handleLoadModel = async () => {
if (!effectiveModelUrl || modelLoadState === 'loading') return
setModelLoadState('loading')
setErrorText(null)
setStatusText('Starting model download…')
setWorkerStage('loading-model')
try {
let model = sharpModelRef.current
if (!model || model.modelUrl !== effectiveModelUrl) {
if (model) await model.dispose()
model = new SharpWebGPUModel({
modelUrl: effectiveModelUrl,
onStatus: (message) => {
setWorkerStage(message.stage)
setStatusText(message.message)
setWorkerProgress(message.progress)
},
})
sharpModelRef.current = model
}
await model.load()
loadedModelUrlRef.current = effectiveModelUrl
setModelLoadState('loaded')
setStatusText('Model loaded. Upload an image and generate.')
setWorkerStage('idle')
} catch (error) {
setModelLoadState('error')
const message = error instanceof Error ? error.message : String(error)
setErrorText(`Model load failed: ${message}`)
setStatusText('Model load failed.')
setWorkerStage('idle')
}
}
const handleResetModel = () => {
const message =
modelLoadState === 'loading'
? 'Cancel the model load? The current download will be aborted.'
: 'Reset the model? This will clear the loaded session and any uploaded file.'
if (!window.confirm(message)) return
const model = sharpModelRef.current
sharpModelRef.current = null
if (model) void model.dispose()
setModelFile(null)
loadedModelUrlRef.current = null
setModelLoadState('idle')
setStatusText('Load the model to begin.')
setWorkerStage('idle')
setWorkerProgress(undefined)
setErrorText(null)
}
const handleCameraChange = useCallback((snap: CameraSnapshot) => {
cameraSnapshotRef.current = snap
}, [])
const handleImageSelection = async (file: File | null) => {
if (!file) return
setErrorText(null)
setStatusText('Reading image metadata…')
let previewUrl: string | null = null
try {
previewUrl = URL.createObjectURL(file)
const info = await readImageInfo(file)
const focalEstimate = await estimateFocalLengthFromFile(file, info.width, info.height)
const nextImage: SelectedImage = {
file,
previewUrl: previewUrl as string,
width: info.width,
height: info.height,
focalEstimate,
}
setSelectedImage((previous) => {
if (previous) URL.revokeObjectURL(previous.previewUrl)
return nextImage
})
controls.setValue('image.focal', focalEstimate.focalPx)
setResult((previous) => {
if (previous) {
URL.revokeObjectURL(previous.previewPlyUrl)
if (previous.downloadPlyUrl !== previous.previewPlyUrl) URL.revokeObjectURL(previous.downloadPlyUrl)
}
return null
})
setGenerationKey((key) => key + 1)
// Auto-generate as soon as an image is ready, provided the model is loaded.
if (modelLoaded) {
void runGeneration(nextImage, focalEstimate.focalPx)
} else {
setStatusText('Image ready. Load the model to generate.')
}
} catch (error) {
if (previewUrl) URL.revokeObjectURL(previewUrl)
setErrorText(error instanceof Error ? error.message : String(error))
setStatusText('Failed to read image.')
}
}
const runGeneration = async (imageArg?: SelectedImage, focalArg?: number) => {
const image = imageArg ?? selectedImage
const focal = focalArg ?? focalPx
if (!image) {
setErrorText('Upload an image before generating.')
return
}
if (!effectiveModelUrl) {
setErrorText('No in-browser model source is available. Upload an ONNX predictor or load the hosted model.')
return
}
if (!modelLoaded) {
setErrorText('Load the model before generating.')
return
}
if (!Number.isFinite(focal) || focal <= 0) {
setErrorText('Focal length must be a positive number.')
return
}
setErrorText(null)
setIsBusy(true)
setStatusText('Preparing image tensor…')
const startTime = performance.now()
try {
const bitmap = await decodeImageBitmap(image.file)
const model = sharpModelRef.current
if (!model || model.modelUrl !== effectiveModelUrl) {
bitmap.close()
throw new Error('The loaded SHARP session no longer matches the selected model. Load it again.')
}
const scene = await (async () => {
try {
return await model.generate(bitmap, {
focalPx: focal,
opacityThreshold: controls.values.generation.opacity,
maxGaussians: controls.values.generation.maxGaussians,
})
} finally {
bitmap.close()
}
})()
const bytes = scene.ply
const blob = new Blob([bytes as BlobPart], { type: 'application/octet-stream' })
const plyUrl = URL.createObjectURL(blob)
const elapsedMs = performance.now() - startTime
const meta = readSharpViewerMeta(bytes)
controls.setValues({
viewer: {
bg: meta?.bgColor ?? DEFAULT_BG_COLOR,
fov: meta?.fov ?? DEFAULT_FOV,
maxScreenSize: meta?.maxScreenSize ?? DEFAULT_MAX_SCREEN_SIZE,
autoRotate: meta?.autoRotate ?? DEFAULT_AUTO_ROTATE,
},
})
startTransition(() => {
setPlyBytes(bytes)
setBakedMeta(meta)
setSplatPosition(meta?.splatPosition ?? DEFAULT_SPLAT_POSITION)
setSplatRotation(meta?.splatRotation ?? DEFAULT_SPLAT_ROTATION)
setSplatFlip(meta?.splatFlip ?? DEFAULT_SPLAT_FLIP)
cameraSnapshotRef.current = null
setSaveStatus(undefined)
setResult((previous) => {
if (previous) {
URL.revokeObjectURL(previous.previewPlyUrl)
if (previous.downloadPlyUrl !== previous.previewPlyUrl) URL.revokeObjectURL(previous.downloadPlyUrl)
}
return {
previewPlyUrl: plyUrl,
downloadPlyUrl: plyUrl,
downloadName: toOutputName(image.file.name),
selectedGaussians: scene.count,
totalGaussians: scene.totalCount,
fileSizeBytes: blob.size,
}
})
setGenerationKey((key) => key + 1)
setStatusText(`Done in ${(elapsedMs / 1000).toFixed(2)}s. Preview and download are ready.`)
setWorkerStage('idle')
})
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error))
setStatusText('Generation failed.')
setWorkerStage('idle')
} finally {
setIsBusy(false)
}
}
const handleSaveDefaults = () => {
if (!plyBytes) {
setSaveStatus('Generate a splat first.')
return
}
const camera = cameraSnapshotRef.current
const meta: SharpViewerMeta = {
...(camera ? { cameraPosition: camera.position, cameraTarget: camera.target, cameraUp: camera.up } : {}),
bgColor: controls.values.viewer.bg,
fov: controls.values.viewer.fov,
autoRotate: controls.values.viewer.autoRotate,
maxScreenSize: controls.values.viewer.maxScreenSize,
...(splatPosition.some((n) => n !== 0) ? { splatPosition } : {}),
...(splatRotation.some((n) => n !== 0) ? { splatRotation } : {}),
...(splatFlip.some(Boolean) ? { splatFlip } : {}),
}
let nextBytes: Uint8Array
try {
nextBytes = writeSharpViewerMeta(plyBytes, meta)
} catch (error) {
setSaveStatus(`Save failed: ${error instanceof Error ? error.message : String(error)}`)
return
}
setPlyBytes(nextBytes)
setBakedMeta(meta)
const blob = new Blob([nextBytes as BlobPart], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
setResult((previous) => {
if (!previous) return previous
if (previous.downloadPlyUrl !== previous.previewPlyUrl) URL.revokeObjectURL(previous.downloadPlyUrl)
return { ...previous, downloadPlyUrl: url, fileSizeBytes: blob.size }
})
setSaveStatus('Baked into download')
window.setTimeout(() => setSaveStatus(undefined), 2000)
}
const handleResetTransform = () => {
setSplatPosition(DEFAULT_SPLAT_POSITION)
setSplatRotation(DEFAULT_SPLAT_ROTATION)
setSplatFlip(DEFAULT_SPLAT_FLIP)
}
const triggerDownload = () => {
if (!result) {
setErrorText('Generate a splat before downloading.')
return
}
const anchor = document.createElement('a')
anchor.href = result.downloadPlyUrl
anchor.download = result.downloadName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
const copyEmbed = async () => {
try {
await navigator.clipboard.writeText(EMBED_SNIPPET)
setSaveStatus('Embed snippet copied')
window.setTimeout(() => setSaveStatus(undefined), 2000)
} catch {
setSaveStatus('Copy failed — clipboard blocked')
}
}
// Reassigned each render so the DialKit action dispatcher always calls the
// latest handlers (which close over current state).
actionsRef.current = {
'model.loadModel': () => void handleLoadModel(),
'model.uploadModel': () => modelInputRef.current?.click(),
'model.resetModel': handleResetModel,
'image.uploadImage': () => imageInputRef.current?.click(),
'image.resetFocal': () => selectedImage && controls.setValue('image.focal', selectedImage.focalEstimate.focalPx),
'generation.generate': () => void runGeneration(),
'export.download': triggerDownload,
'export.copyEmbed': () => void copyEmbed(),
'viewer.flipX': () => setSplatFlip((f) => [!f[0], f[1], f[2]]),
'viewer.flipY': () => setSplatFlip((f) => [f[0], !f[1], f[2]]),
'viewer.flipZ': () => setSplatFlip((f) => [f[0], f[1], !f[2]]),
'viewer.resetFlip': handleResetTransform,
'viewer.saveDefaults': handleSaveDefaults,
}
const resultRatio =
result && result.totalGaussians > 0 ? (100 * result.selectedGaussians) / result.totalGaussians : 0
const resultSummary = result
? `${result.selectedGaussians.toLocaleString()} / ${result.totalGaussians.toLocaleString()} gaussians (${resultRatio.toFixed(1)}%) • ${formatBytes(result.fileSizeBytes)}`
: null
const previewProps = {
plyUrl: result?.previewPlyUrl ?? null,
generationKey,
initialCameraPosition: bakedMeta?.cameraPosition,
initialCameraTarget: bakedMeta?.cameraTarget,
initialCameraUp: bakedMeta?.cameraUp,
bgColor: controls.values.viewer.bg,
fov: controls.values.viewer.fov,
autoRotate: controls.values.viewer.autoRotate,
splatPosition,
splatRotation,
splatFlip,
onCameraChange: handleCameraChange,
}
const modelLoading = modelLoadState === 'loading'
const showProgress = workerProgress !== undefined && workerProgress < 1
return (
<div className="stage">
{renderer === 'aholo' ? (
<AholoSplatPreview {...previewProps} />
) : (
<SplatPreview {...previewProps} maxScreenSize={controls.values.viewer.maxScreenSize} />
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null
event.currentTarget.value = ''
void handleImageSelection(file)
}}
/>
<input
ref={modelInputRef}
type="file"
accept=".onnx,application/octet-stream"
hidden
onChange={(event) => {
setModelFile(event.currentTarget.files?.[0] ?? null)
event.currentTarget.value = ''
}}
/>
<div className="overlays">
<div className="status-overlay" data-stage={workerStage}>
<div className="status-overlay-row">
<span className={`status-dot${isBusy || modelLoading ? ' is-busy' : ''}`} />
<span className="status-overlay-text">
{modelLoading
? `Loading model ${String(Math.floor(loadElapsedSec / 60)).padStart(2, '0')}:${String(loadElapsedSec % 60).padStart(2, '0')}`
: statusText}
</span>
</div>
{showProgress ? (
<div
className="progress-bar"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round((workerProgress ?? 0) * 100)}
>
<div className="progress-bar-fill" style={{ width: `${((workerProgress ?? 0) * 100).toFixed(1)}%` }} />
</div>
) : null}
{errorText ? <p className="status-overlay-error">{errorText}</p> : null}
{resultSummary ? <p className="status-overlay-summary">{resultSummary}</p> : null}
{saveStatus ? <p className="status-overlay-save">{saveStatus}</p> : null}
</div>
{selectedImage ? (
<figure className="thumb-overlay">
<img src={selectedImage.previewUrl} alt="Input" />
<figcaption>
{selectedImage.width}×{selectedImage.height} · {sourceLabel(selectedImage.focalEstimate.source)}
</figcaption>
</figure>
) : null}
<div className="links-overlay">
<a href="https://x.com/bringshrubberyy" target="_blank" rel="noreferrer" aria-label="X (Twitter)" title="X (Twitter)">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<a href="https://github.com/bring-shrubbery/ml-sharp-web" target="_blank" rel="noreferrer" aria-label="GitHub repository" title="GitHub repository">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.7-3.87-1.36-3.87-1.36-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.72-1.53-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.9-.39.99 0 1.98.13 2.9.39 2.21-1.49 3.18-1.18 3.18-1.18.62 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.7 5.39-5.27 5.68.41.36.78 1.06.78 2.14 0 1.55-.01 2.79-.01 3.17 0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5z" />
</svg>
</a>
<a
className="links-overlay-license"
href="https://github.com/apple/ml-sharp/blob/main/LICENSE_MODEL"
target="_blank"
rel="noreferrer"
>
Model license
</a>
</div>
</div>
<DialRoot mode="popover" theme="dark" position="top-right" productionEnabled defaultOpen />
</div>
)
}
export default App