Spaces:
Running
Running
| 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 | |