cjgaspari
commited on
Commit
Β·
6fe70f4
1
Parent(s):
c20d7cc
Added web ui;
Browse filesAdded start.command for one-click setup; Updated readme.md for one-click setup;
Added images of web-ui;
Added new web viewer after ply is generated;
Cleaned up project by moving styles and js into seperate files;
- README.md +15 -0
- src/sharp/web/README.md +33 -0
- src/sharp/web/app.py +184 -0
- src/sharp/web/requirements.txt +4 -0
- src/sharp/web/static/css/styles.css +724 -0
- src/sharp/web/static/js/main.js +331 -0
- src/sharp/web/static/js/viewer.js +175 -0
- src/sharp/web/templates/index.html +151 -0
- start.command +94 -0
README.md
CHANGED
|
@@ -89,6 +89,21 @@ If you find our work useful, please cite the following paper:
|
|
| 89 |
|
| 90 |
Our codebase is built using multiple opensource contributions, please see [ACKNOWLEDGEMENTS](ACKNOWLEDGEMENTS) for more details.
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
## License
|
| 93 |
|
| 94 |
Please check out the repository [LICENSE](LICENSE) before using the provided code and
|
|
|
|
| 89 |
|
| 90 |
Our codebase is built using multiple opensource contributions, please see [ACKNOWLEDGEMENTS](ACKNOWLEDGEMENTS) for more details.
|
| 91 |
|
| 92 |
+
## Web Interface (One-Click Setup)
|
| 93 |
+
|
| 94 |
+
For a simple web interface where you can upload images and download 3D Gaussians:
|
| 95 |
+
|
| 96 |
+
**macOS:** Double-click `start.command` in Finder.
|
| 97 |
+
|
| 98 |
+
This will automatically:
|
| 99 |
+
- Create the conda environment if it doesn't exist
|
| 100 |
+
- Install all dependencies if needed
|
| 101 |
+
- Start the web server at http://localhost:8000
|
| 102 |
+
|
| 103 |
+

|
| 104 |
+

|
| 105 |
+
|
| 106 |
+
|
| 107 |
## License
|
| 108 |
|
| 109 |
Please check out the repository [LICENSE](LICENSE) before using the provided code and
|
src/sharp/web/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sharp Web Interface
|
| 2 |
+
|
| 3 |
+
This is a web interface for the Sharp 3D prediction model.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
Make sure you have the `sharp` package installed (see root README).
|
| 8 |
+
Install the web dependencies:
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
pip install -r requirements.txt
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
## Running the Server
|
| 15 |
+
|
| 16 |
+
Run the following command from the `web` directory:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
python app.py
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Or using uvicorn directly:
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Usage
|
| 29 |
+
|
| 30 |
+
1. Open your browser and navigate to `http://localhost:8000`.
|
| 31 |
+
2. Drag and drop images or click to select them.
|
| 32 |
+
3. Click "Predict 3D Gaussians".
|
| 33 |
+
4. A zip file containing the resulting `.ply` files will be downloaded automatically.
|
src/sharp/web/app.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import logging
|
| 4 |
+
import shutil
|
| 5 |
+
import tempfile
|
| 6 |
+
import zipfile
|
| 7 |
+
import io as python_io
|
| 8 |
+
import base64
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, Request, UploadFile, File
|
| 11 |
+
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
import torch
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
# Add src to path so we can import sharp
|
| 18 |
+
sys.path.append(str(Path(__file__).parent.parent / "src"))
|
| 19 |
+
|
| 20 |
+
from sharp.models import (
|
| 21 |
+
PredictorParams,
|
| 22 |
+
RGBGaussianPredictor,
|
| 23 |
+
create_predictor,
|
| 24 |
+
)
|
| 25 |
+
from sharp.utils import io as sharp_io
|
| 26 |
+
from sharp.utils.gaussians import save_ply
|
| 27 |
+
from sharp.cli.predict import predict_image, DEFAULT_MODEL_URL
|
| 28 |
+
|
| 29 |
+
# Configure logging
|
| 30 |
+
logging.basicConfig(level=logging.INFO)
|
| 31 |
+
LOGGER = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
app = FastAPI()
|
| 34 |
+
|
| 35 |
+
# Mount static files if needed (we created the dir)
|
| 36 |
+
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
| 37 |
+
|
| 38 |
+
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
| 39 |
+
|
| 40 |
+
# Global variables for the model
|
| 41 |
+
predictor: RGBGaussianPredictor = None
|
| 42 |
+
device: torch.device = None
|
| 43 |
+
|
| 44 |
+
@app.on_event("startup")
|
| 45 |
+
async def startup_event():
|
| 46 |
+
global predictor, device
|
| 47 |
+
|
| 48 |
+
# Determine device
|
| 49 |
+
if torch.cuda.is_available():
|
| 50 |
+
device_str = "cuda"
|
| 51 |
+
elif torch.mps.is_available():
|
| 52 |
+
device_str = "mps"
|
| 53 |
+
else:
|
| 54 |
+
device_str = "cpu"
|
| 55 |
+
|
| 56 |
+
device = torch.device(device_str)
|
| 57 |
+
LOGGER.info(f"Using device: {device}")
|
| 58 |
+
|
| 59 |
+
# Load model
|
| 60 |
+
LOGGER.info("Loading model...")
|
| 61 |
+
try:
|
| 62 |
+
# Try to load from cache or download
|
| 63 |
+
state_dict = torch.hub.load_state_dict_from_url(DEFAULT_MODEL_URL, progress=True, map_location=device)
|
| 64 |
+
|
| 65 |
+
predictor = create_predictor(PredictorParams())
|
| 66 |
+
predictor.load_state_dict(state_dict)
|
| 67 |
+
predictor.eval()
|
| 68 |
+
predictor.to(device)
|
| 69 |
+
LOGGER.info("Model loaded successfully.")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
LOGGER.error(f"Failed to load model: {e}")
|
| 72 |
+
raise e
|
| 73 |
+
|
| 74 |
+
@app.get("/", response_class=HTMLResponse)
|
| 75 |
+
async def read_root(request: Request):
|
| 76 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 77 |
+
|
| 78 |
+
@app.post("/predict")
|
| 79 |
+
async def predict(files: list[UploadFile] = File(...)):
|
| 80 |
+
"""Process images and return PLY data for viewing or download."""
|
| 81 |
+
if not predictor:
|
| 82 |
+
return JSONResponse({"error": "Model not loaded"}, status_code=500)
|
| 83 |
+
|
| 84 |
+
# Create a temporary directory to process files
|
| 85 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 86 |
+
temp_path = Path(temp_dir)
|
| 87 |
+
results = []
|
| 88 |
+
|
| 89 |
+
for file in files:
|
| 90 |
+
try:
|
| 91 |
+
# Save uploaded file
|
| 92 |
+
file_path = temp_path / file.filename
|
| 93 |
+
with open(file_path, "wb") as buffer:
|
| 94 |
+
shutil.copyfileobj(file.file, buffer)
|
| 95 |
+
|
| 96 |
+
LOGGER.info(f"Processing {file.filename}")
|
| 97 |
+
|
| 98 |
+
# Load image using sharp's IO to get focal length and handle rotation
|
| 99 |
+
image, _, f_px = sharp_io.load_rgb(file_path)
|
| 100 |
+
|
| 101 |
+
# Run prediction
|
| 102 |
+
gaussians = predict_image(predictor, image, f_px, device)
|
| 103 |
+
|
| 104 |
+
# Save PLY
|
| 105 |
+
ply_filename = f"{file_path.stem}.ply"
|
| 106 |
+
ply_path = temp_path / ply_filename
|
| 107 |
+
|
| 108 |
+
height, width = image.shape[:2]
|
| 109 |
+
save_ply(gaussians, f_px, (height, width), ply_path)
|
| 110 |
+
|
| 111 |
+
# Read PLY file and encode as base64
|
| 112 |
+
with open(ply_path, "rb") as f:
|
| 113 |
+
ply_data = base64.b64encode(f.read()).decode("utf-8")
|
| 114 |
+
|
| 115 |
+
results.append({
|
| 116 |
+
"filename": file.filename,
|
| 117 |
+
"ply_filename": ply_filename,
|
| 118 |
+
"ply_data": ply_data,
|
| 119 |
+
"width": width,
|
| 120 |
+
"height": height,
|
| 121 |
+
"focal_length": f_px,
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
LOGGER.error(f"Error processing {file.filename}: {e}")
|
| 126 |
+
results.append({
|
| 127 |
+
"filename": file.filename,
|
| 128 |
+
"error": str(e),
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
return JSONResponse({"results": results})
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/predict/download")
|
| 135 |
+
async def predict_download(files: list[UploadFile] = File(...)):
|
| 136 |
+
"""Process images and return a ZIP file for download."""
|
| 137 |
+
if not predictor:
|
| 138 |
+
return HTMLResponse("Model not loaded", status_code=500)
|
| 139 |
+
|
| 140 |
+
# Create a temporary directory to process files
|
| 141 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 142 |
+
temp_path = Path(temp_dir)
|
| 143 |
+
output_zip = python_io.BytesIO()
|
| 144 |
+
|
| 145 |
+
with zipfile.ZipFile(output_zip, "w") as zf:
|
| 146 |
+
for file in files:
|
| 147 |
+
try:
|
| 148 |
+
# Save uploaded file
|
| 149 |
+
file_path = temp_path / file.filename
|
| 150 |
+
with open(file_path, "wb") as buffer:
|
| 151 |
+
shutil.copyfileobj(file.file, buffer)
|
| 152 |
+
|
| 153 |
+
LOGGER.info(f"Processing {file.filename}")
|
| 154 |
+
|
| 155 |
+
# Load image using sharp's IO to get focal length and handle rotation
|
| 156 |
+
image, _, f_px = sharp_io.load_rgb(file_path)
|
| 157 |
+
|
| 158 |
+
# Run prediction
|
| 159 |
+
gaussians = predict_image(predictor, image, f_px, device)
|
| 160 |
+
|
| 161 |
+
# Save PLY
|
| 162 |
+
ply_filename = f"{file_path.stem}.ply"
|
| 163 |
+
ply_path = temp_path / ply_filename
|
| 164 |
+
|
| 165 |
+
height, width = image.shape[:2]
|
| 166 |
+
save_ply(gaussians, f_px, (height, width), ply_path)
|
| 167 |
+
|
| 168 |
+
# Add to zip
|
| 169 |
+
zf.write(ply_path, ply_filename)
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
LOGGER.error(f"Error processing {file.filename}: {e}")
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
output_zip.seek(0)
|
| 176 |
+
return StreamingResponse(
|
| 177 |
+
output_zip,
|
| 178 |
+
media_type="application/zip",
|
| 179 |
+
headers={"Content-Disposition": "attachment; filename=gaussians.zip"}
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
import uvicorn
|
| 184 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
src/sharp/web/requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
jinja2
|
src/sharp/web/static/css/styles.css
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
/* Dark mode (default) */
|
| 3 |
+
--bg-primary: #0a0a0f;
|
| 4 |
+
--bg-secondary: #1a1a2e;
|
| 5 |
+
--bg-tertiary: #0f0f1a;
|
| 6 |
+
--text-primary: #ffffff;
|
| 7 |
+
--text-secondary: rgba(255, 255, 255, 0.9);
|
| 8 |
+
--text-muted: rgba(255, 255, 255, 0.6);
|
| 9 |
+
--text-dim: rgba(255, 255, 255, 0.4);
|
| 10 |
+
--text-dimmer: rgba(255, 255, 255, 0.3);
|
| 11 |
+
--card-bg: rgba(255, 255, 255, 0.03);
|
| 12 |
+
--card-border: rgba(255, 255, 255, 0.08);
|
| 13 |
+
--card-highlight: rgba(255, 255, 255, 0.05);
|
| 14 |
+
--upload-bg: rgba(255, 255, 255, 0.02);
|
| 15 |
+
--upload-border: rgba(255, 255, 255, 0.15);
|
| 16 |
+
--file-item-bg: rgba(255, 255, 255, 0.05);
|
| 17 |
+
--loader-bg: rgba(255, 255, 255, 0.03);
|
| 18 |
+
--particle-connection: rgba(255, 255, 255, 0.02);
|
| 19 |
+
--glow-color: rgba(99, 102, 241, 0.1);
|
| 20 |
+
--shadow-color: rgba(0, 0, 0, 0.3);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
[data-theme="light"] {
|
| 24 |
+
--bg-primary: #f8fafc;
|
| 25 |
+
--bg-secondary: #e2e8f0;
|
| 26 |
+
--bg-tertiary: #f1f5f9;
|
| 27 |
+
--text-primary: #0f172a;
|
| 28 |
+
--text-secondary: #1e293b;
|
| 29 |
+
--text-muted: #64748b;
|
| 30 |
+
--text-dim: #94a3b8;
|
| 31 |
+
--text-dimmer: #cbd5e1;
|
| 32 |
+
--card-bg: rgba(255, 255, 255, 0.8);
|
| 33 |
+
--card-border: rgba(0, 0, 0, 0.08);
|
| 34 |
+
--card-highlight: rgba(255, 255, 255, 0.5);
|
| 35 |
+
--upload-bg: rgba(99, 102, 241, 0.02);
|
| 36 |
+
--upload-border: rgba(99, 102, 241, 0.2);
|
| 37 |
+
--file-item-bg: rgba(99, 102, 241, 0.05);
|
| 38 |
+
--loader-bg: rgba(99, 102, 241, 0.05);
|
| 39 |
+
--particle-connection: rgba(99, 102, 241, 0.08);
|
| 40 |
+
--glow-color: rgba(99, 102, 241, 0.15);
|
| 41 |
+
--shadow-color: rgba(99, 102, 241, 0.1);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
* {
|
| 45 |
+
margin: 0;
|
| 46 |
+
padding: 0;
|
| 47 |
+
box-sizing: border-box;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 52 |
+
min-height: 100vh;
|
| 53 |
+
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, var(--bg-tertiary) 100%);
|
| 54 |
+
color: var(--text-primary);
|
| 55 |
+
overflow-x: hidden;
|
| 56 |
+
transition: background 0.3s ease, color 0.3s ease;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#particleCanvas {
|
| 60 |
+
position: fixed;
|
| 61 |
+
top: 0;
|
| 62 |
+
left: 0;
|
| 63 |
+
width: 100%;
|
| 64 |
+
height: 100%;
|
| 65 |
+
z-index: 0;
|
| 66 |
+
pointer-events: none;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.app-container {
|
| 70 |
+
position: relative;
|
| 71 |
+
z-index: 1;
|
| 72 |
+
min-height: 100vh;
|
| 73 |
+
display: flex;
|
| 74 |
+
flex-direction: column;
|
| 75 |
+
align-items: center;
|
| 76 |
+
justify-content: center;
|
| 77 |
+
padding: 2rem;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.header {
|
| 81 |
+
text-align: center;
|
| 82 |
+
margin-bottom: 2.5rem;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.logo {
|
| 86 |
+
font-size: 3rem;
|
| 87 |
+
font-weight: 700;
|
| 88 |
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
|
| 89 |
+
-webkit-background-clip: text;
|
| 90 |
+
-webkit-text-fill-color: transparent;
|
| 91 |
+
background-clip: text;
|
| 92 |
+
margin-bottom: 0.5rem;
|
| 93 |
+
letter-spacing: -0.02em;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.tagline {
|
| 97 |
+
font-size: 1.1rem;
|
| 98 |
+
color: var(--text-muted);
|
| 99 |
+
font-weight: 400;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.theme-toggle {
|
| 103 |
+
position: fixed;
|
| 104 |
+
top: 1.5rem;
|
| 105 |
+
right: 1.5rem;
|
| 106 |
+
z-index: 100;
|
| 107 |
+
width: 48px;
|
| 108 |
+
height: 48px;
|
| 109 |
+
border-radius: 50%;
|
| 110 |
+
border: 1px solid var(--card-border);
|
| 111 |
+
background: var(--card-bg);
|
| 112 |
+
backdrop-filter: blur(10px);
|
| 113 |
+
-webkit-backdrop-filter: blur(10px);
|
| 114 |
+
cursor: pointer;
|
| 115 |
+
display: flex;
|
| 116 |
+
align-items: center;
|
| 117 |
+
justify-content: center;
|
| 118 |
+
transition: all 0.3s ease;
|
| 119 |
+
box-shadow: 0 4px 12px var(--shadow-color);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.theme-toggle:hover {
|
| 123 |
+
transform: scale(1.1);
|
| 124 |
+
border-color: #a855f7;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.theme-toggle svg {
|
| 128 |
+
width: 22px;
|
| 129 |
+
height: 22px;
|
| 130 |
+
transition: all 0.3s ease;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.theme-toggle .sun-icon {
|
| 134 |
+
stroke: #f59e0b;
|
| 135 |
+
display: none;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.theme-toggle .moon-icon {
|
| 139 |
+
stroke: var(--text-muted);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
[data-theme="light"] .theme-toggle .sun-icon {
|
| 143 |
+
display: block;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
[data-theme="light"] .theme-toggle .moon-icon {
|
| 147 |
+
display: none;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.card {
|
| 151 |
+
background: var(--card-bg);
|
| 152 |
+
backdrop-filter: blur(20px);
|
| 153 |
+
-webkit-backdrop-filter: blur(20px);
|
| 154 |
+
border: 1px solid var(--card-border);
|
| 155 |
+
border-radius: 24px;
|
| 156 |
+
padding: 2.5rem;
|
| 157 |
+
width: 100%;
|
| 158 |
+
max-width: 520px;
|
| 159 |
+
box-shadow:
|
| 160 |
+
0 4px 24px var(--shadow-color),
|
| 161 |
+
0 0 80px var(--glow-color),
|
| 162 |
+
inset 0 1px 0 var(--card-highlight);
|
| 163 |
+
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
[data-theme="light"] .card {
|
| 167 |
+
background: rgba(255, 255, 255, 0.12);
|
| 168 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 169 |
+
backdrop-filter: blur(16px);
|
| 170 |
+
-webkit-backdrop-filter: blur(16px);
|
| 171 |
+
box-shadow:
|
| 172 |
+
0 8px 32px rgba(99, 102, 241, 0.1),
|
| 173 |
+
0 0 60px rgba(99, 102, 241, 0.06),
|
| 174 |
+
0 0 0 1px rgba(255, 255, 255, 0.3) inset;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.upload-zone {
|
| 178 |
+
border: 2px dashed var(--upload-border);
|
| 179 |
+
border-radius: 16px;
|
| 180 |
+
padding: 3rem 2rem;
|
| 181 |
+
text-align: center;
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
transition: all 0.3s ease;
|
| 184 |
+
background: var(--upload-bg);
|
| 185 |
+
backdrop-filter: blur(10px);
|
| 186 |
+
-webkit-backdrop-filter: blur(10px);
|
| 187 |
+
position: relative;
|
| 188 |
+
overflow: hidden;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
[data-theme="light"] .upload-zone {
|
| 192 |
+
background: rgba(255, 255, 255, 0.1);
|
| 193 |
+
border-color: rgba(99, 102, 241, 0.25);
|
| 194 |
+
box-shadow:
|
| 195 |
+
0 4px 16px rgba(99, 102, 241, 0.08),
|
| 196 |
+
0 0 0 1px rgba(255, 255, 255, 0.25) inset;
|
| 197 |
+
backdrop-filter: blur(12px);
|
| 198 |
+
-webkit-backdrop-filter: blur(12px);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
[data-theme="light"] .upload-zone:hover {
|
| 202 |
+
background: rgba(255, 255, 255, 0.18);
|
| 203 |
+
border-color: rgba(99, 102, 241, 0.4);
|
| 204 |
+
box-shadow:
|
| 205 |
+
0 8px 24px rgba(99, 102, 241, 0.12),
|
| 206 |
+
0 0 0 1px rgba(255, 255, 255, 0.35) inset;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
[data-theme="light"] .upload-zone.has-files {
|
| 210 |
+
background: rgba(34, 197, 94, 0.06);
|
| 211 |
+
border-color: rgba(34, 197, 94, 0.35);
|
| 212 |
+
box-shadow:
|
| 213 |
+
0 4px 16px rgba(34, 197, 94, 0.08),
|
| 214 |
+
0 0 0 1px rgba(255, 255, 255, 0.25) inset;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.upload-zone::before {
|
| 218 |
+
content: '';
|
| 219 |
+
position: absolute;
|
| 220 |
+
inset: 0;
|
| 221 |
+
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%);
|
| 222 |
+
opacity: 0;
|
| 223 |
+
transition: opacity 0.3s ease;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.upload-zone:hover {
|
| 227 |
+
border-color: rgba(99, 102, 241, 0.5);
|
| 228 |
+
transform: translateY(-2px);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.upload-zone:hover::before {
|
| 232 |
+
opacity: 1;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.upload-zone.drag-over {
|
| 236 |
+
border-color: #a855f7;
|
| 237 |
+
background: rgba(168, 85, 247, 0.1);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.upload-zone.has-files {
|
| 241 |
+
border-color: rgba(34, 197, 94, 0.5);
|
| 242 |
+
background: rgba(34, 197, 94, 0.05);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.upload-icon {
|
| 246 |
+
width: 64px;
|
| 247 |
+
height: 64px;
|
| 248 |
+
margin: 0 auto 1.25rem;
|
| 249 |
+
position: relative;
|
| 250 |
+
z-index: 1;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.upload-icon svg {
|
| 254 |
+
width: 100%;
|
| 255 |
+
height: 100%;
|
| 256 |
+
stroke: var(--text-dim);
|
| 257 |
+
transition: stroke 0.3s ease;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.upload-zone:hover .upload-icon svg {
|
| 261 |
+
stroke: #a855f7;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.upload-text {
|
| 265 |
+
position: relative;
|
| 266 |
+
z-index: 1;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.upload-text h3 {
|
| 270 |
+
font-size: 1.1rem;
|
| 271 |
+
font-weight: 500;
|
| 272 |
+
margin-bottom: 0.5rem;
|
| 273 |
+
color: var(--text-secondary);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.upload-text p {
|
| 277 |
+
font-size: 0.875rem;
|
| 278 |
+
color: var(--text-dim);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.file-list {
|
| 282 |
+
margin-top: 1.5rem;
|
| 283 |
+
display: flex;
|
| 284 |
+
flex-direction: column;
|
| 285 |
+
gap: 0.5rem;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.file-item {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
gap: 0.75rem;
|
| 292 |
+
padding: 0.75rem 1rem;
|
| 293 |
+
background: var(--file-item-bg);
|
| 294 |
+
border-radius: 10px;
|
| 295 |
+
animation: slideIn 0.3s ease;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
@keyframes slideIn {
|
| 299 |
+
from {
|
| 300 |
+
opacity: 0;
|
| 301 |
+
transform: translateX(-10px);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
to {
|
| 305 |
+
opacity: 1;
|
| 306 |
+
transform: translateX(0);
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.file-item .file-icon {
|
| 311 |
+
width: 36px;
|
| 312 |
+
height: 36px;
|
| 313 |
+
border-radius: 8px;
|
| 314 |
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
| 315 |
+
display: flex;
|
| 316 |
+
align-items: center;
|
| 317 |
+
justify-content: center;
|
| 318 |
+
flex-shrink: 0;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.file-item .file-icon svg {
|
| 322 |
+
width: 18px;
|
| 323 |
+
height: 18px;
|
| 324 |
+
stroke: white;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.file-item .file-info {
|
| 328 |
+
flex: 1;
|
| 329 |
+
min-width: 0;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.file-item .file-name {
|
| 333 |
+
font-size: 0.875rem;
|
| 334 |
+
font-weight: 500;
|
| 335 |
+
color: var(--text-secondary);
|
| 336 |
+
white-space: nowrap;
|
| 337 |
+
overflow: hidden;
|
| 338 |
+
text-overflow: ellipsis;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.file-item .file-size {
|
| 342 |
+
font-size: 0.75rem;
|
| 343 |
+
color: var(--text-dim);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.file-item .file-preview {
|
| 347 |
+
width: 36px;
|
| 348 |
+
height: 36px;
|
| 349 |
+
border-radius: 8px;
|
| 350 |
+
object-fit: cover;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.submit-btn {
|
| 354 |
+
width: 100%;
|
| 355 |
+
margin-top: 1.5rem;
|
| 356 |
+
padding: 1rem 1.5rem;
|
| 357 |
+
font-size: 1rem;
|
| 358 |
+
font-weight: 600;
|
| 359 |
+
font-family: inherit;
|
| 360 |
+
color: white;
|
| 361 |
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
|
| 362 |
+
border: none;
|
| 363 |
+
border-radius: 12px;
|
| 364 |
+
cursor: pointer;
|
| 365 |
+
transition: all 0.3s ease;
|
| 366 |
+
position: relative;
|
| 367 |
+
overflow: hidden;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.submit-btn::before {
|
| 371 |
+
content: '';
|
| 372 |
+
position: absolute;
|
| 373 |
+
inset: 0;
|
| 374 |
+
background: linear-gradient(135deg, #4f46e5 0%, #9333ea 50%, #db2777 100%);
|
| 375 |
+
opacity: 0;
|
| 376 |
+
transition: opacity 0.3s ease;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.submit-btn:hover:not(:disabled) {
|
| 380 |
+
transform: translateY(-2px);
|
| 381 |
+
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.4);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.submit-btn:hover:not(:disabled)::before {
|
| 385 |
+
opacity: 1;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.submit-btn:disabled {
|
| 389 |
+
opacity: 0.5;
|
| 390 |
+
cursor: not-allowed;
|
| 391 |
+
transform: none;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.submit-btn span {
|
| 395 |
+
position: relative;
|
| 396 |
+
z-index: 1;
|
| 397 |
+
display: flex;
|
| 398 |
+
align-items: center;
|
| 399 |
+
justify-content: center;
|
| 400 |
+
gap: 0.5rem;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.loader-container {
|
| 404 |
+
display: none;
|
| 405 |
+
flex-direction: column;
|
| 406 |
+
align-items: center;
|
| 407 |
+
margin-top: 2rem;
|
| 408 |
+
padding: 2rem;
|
| 409 |
+
background: var(--loader-bg);
|
| 410 |
+
border-radius: 16px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.loader-container.active {
|
| 414 |
+
display: flex;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.loader {
|
| 418 |
+
width: 48px;
|
| 419 |
+
height: 48px;
|
| 420 |
+
position: relative;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.loader::before,
|
| 424 |
+
.loader::after {
|
| 425 |
+
content: '';
|
| 426 |
+
position: absolute;
|
| 427 |
+
inset: 0;
|
| 428 |
+
border-radius: 50%;
|
| 429 |
+
border: 3px solid transparent;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.loader::before {
|
| 433 |
+
border-top-color: #6366f1;
|
| 434 |
+
animation: spin 1s linear infinite;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.loader::after {
|
| 438 |
+
border-right-color: #a855f7;
|
| 439 |
+
animation: spin 1.5s linear infinite reverse;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
@keyframes spin {
|
| 443 |
+
to {
|
| 444 |
+
transform: rotate(360deg);
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.loader-text {
|
| 449 |
+
margin-top: 1rem;
|
| 450 |
+
font-size: 0.875rem;
|
| 451 |
+
color: var(--text-muted);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.loader-subtext {
|
| 455 |
+
font-size: 0.75rem;
|
| 456 |
+
color: var(--text-dimmer);
|
| 457 |
+
margin-top: 0.25rem;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.results {
|
| 461 |
+
margin-top: 1.5rem;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.result-item {
|
| 465 |
+
display: flex;
|
| 466 |
+
align-items: center;
|
| 467 |
+
gap: 0.75rem;
|
| 468 |
+
padding: 1rem 1.25rem;
|
| 469 |
+
background: rgba(34, 197, 94, 0.1);
|
| 470 |
+
border: 1px solid rgba(34, 197, 94, 0.2);
|
| 471 |
+
border-radius: 12px;
|
| 472 |
+
animation: fadeIn 0.5s ease;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
@keyframes fadeIn {
|
| 476 |
+
from {
|
| 477 |
+
opacity: 0;
|
| 478 |
+
transform: scale(0.95);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
to {
|
| 482 |
+
opacity: 1;
|
| 483 |
+
transform: scale(1);
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.result-item .success-icon {
|
| 488 |
+
width: 40px;
|
| 489 |
+
height: 40px;
|
| 490 |
+
background: rgba(34, 197, 94, 0.2);
|
| 491 |
+
border-radius: 50%;
|
| 492 |
+
display: flex;
|
| 493 |
+
align-items: center;
|
| 494 |
+
justify-content: center;
|
| 495 |
+
flex-shrink: 0;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.result-item .success-icon svg {
|
| 499 |
+
width: 20px;
|
| 500 |
+
height: 20px;
|
| 501 |
+
stroke: #22c55e;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.result-item .result-text {
|
| 505 |
+
flex: 1;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.result-item .result-title {
|
| 509 |
+
font-weight: 600;
|
| 510 |
+
color: #22c55e;
|
| 511 |
+
font-size: 0.9375rem;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.result-item .result-desc {
|
| 515 |
+
font-size: 0.8125rem;
|
| 516 |
+
color: var(--text-muted);
|
| 517 |
+
margin-top: 0.125rem;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.footer {
|
| 521 |
+
margin-top: 2rem;
|
| 522 |
+
text-align: center;
|
| 523 |
+
font-size: 0.8125rem;
|
| 524 |
+
color: var(--text-dimmer);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.footer a {
|
| 528 |
+
color: var(--text-muted);
|
| 529 |
+
text-decoration: none;
|
| 530 |
+
transition: color 0.2s ease;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.footer a:hover {
|
| 534 |
+
color: #a855f7;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* Viewer styles */
|
| 538 |
+
.viewer-container {
|
| 539 |
+
display: none;
|
| 540 |
+
flex-direction: column;
|
| 541 |
+
width: 100%;
|
| 542 |
+
max-width: 900px;
|
| 543 |
+
margin-top: 1.5rem;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.viewer-container.active {
|
| 547 |
+
display: flex;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.viewer-header {
|
| 551 |
+
display: flex;
|
| 552 |
+
align-items: center;
|
| 553 |
+
justify-content: space-between;
|
| 554 |
+
padding: 1rem 1.25rem;
|
| 555 |
+
background: var(--card-bg);
|
| 556 |
+
border: 1px solid var(--card-border);
|
| 557 |
+
border-radius: 16px 16px 0 0;
|
| 558 |
+
backdrop-filter: blur(10px);
|
| 559 |
+
-webkit-backdrop-filter: blur(10px);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.viewer-title {
|
| 563 |
+
display: flex;
|
| 564 |
+
align-items: center;
|
| 565 |
+
gap: 0.75rem;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.viewer-title h3 {
|
| 569 |
+
font-size: 1rem;
|
| 570 |
+
font-weight: 600;
|
| 571 |
+
color: var(--text-secondary);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.viewer-title .file-badge {
|
| 575 |
+
font-size: 0.75rem;
|
| 576 |
+
padding: 0.25rem 0.75rem;
|
| 577 |
+
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2));
|
| 578 |
+
border-radius: 20px;
|
| 579 |
+
color: var(--text-muted);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.viewer-actions {
|
| 583 |
+
display: flex;
|
| 584 |
+
gap: 0.5rem;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.viewer-btn {
|
| 588 |
+
padding: 0.5rem 1rem;
|
| 589 |
+
font-size: 0.875rem;
|
| 590 |
+
font-weight: 500;
|
| 591 |
+
font-family: inherit;
|
| 592 |
+
color: var(--text-secondary);
|
| 593 |
+
background: var(--file-item-bg);
|
| 594 |
+
border: 1px solid var(--card-border);
|
| 595 |
+
border-radius: 8px;
|
| 596 |
+
cursor: pointer;
|
| 597 |
+
transition: all 0.2s ease;
|
| 598 |
+
display: flex;
|
| 599 |
+
align-items: center;
|
| 600 |
+
gap: 0.5rem;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.viewer-btn:hover {
|
| 604 |
+
background: rgba(99, 102, 241, 0.15);
|
| 605 |
+
border-color: rgba(99, 102, 241, 0.3);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.viewer-btn svg {
|
| 609 |
+
width: 16px;
|
| 610 |
+
height: 16px;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.viewer-btn.primary {
|
| 614 |
+
background: linear-gradient(135deg, #6366f1, #a855f7);
|
| 615 |
+
border: none;
|
| 616 |
+
color: white;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.viewer-btn.primary:hover {
|
| 620 |
+
transform: translateY(-1px);
|
| 621 |
+
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.viewer-canvas-container {
|
| 625 |
+
position: relative;
|
| 626 |
+
width: 100%;
|
| 627 |
+
height: 500px;
|
| 628 |
+
background: #000;
|
| 629 |
+
border-left: 1px solid var(--card-border);
|
| 630 |
+
border-right: 1px solid var(--card-border);
|
| 631 |
+
overflow: hidden;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.viewer-canvas-container canvas {
|
| 635 |
+
width: 100% !important;
|
| 636 |
+
height: 100% !important;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.viewer-controls-hint {
|
| 640 |
+
position: absolute;
|
| 641 |
+
bottom: 1rem;
|
| 642 |
+
left: 50%;
|
| 643 |
+
transform: translateX(-50%);
|
| 644 |
+
padding: 0.5rem 1rem;
|
| 645 |
+
background: rgba(0, 0, 0, 0.7);
|
| 646 |
+
backdrop-filter: blur(10px);
|
| 647 |
+
border-radius: 8px;
|
| 648 |
+
font-size: 0.75rem;
|
| 649 |
+
color: rgba(255, 255, 255, 0.7);
|
| 650 |
+
pointer-events: none;
|
| 651 |
+
transition: opacity 0.3s ease;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.viewer-footer {
|
| 655 |
+
display: flex;
|
| 656 |
+
align-items: center;
|
| 657 |
+
justify-content: center;
|
| 658 |
+
padding: 0.75rem;
|
| 659 |
+
background: var(--card-bg);
|
| 660 |
+
border: 1px solid var(--card-border);
|
| 661 |
+
border-radius: 0 0 16px 16px;
|
| 662 |
+
backdrop-filter: blur(10px);
|
| 663 |
+
-webkit-backdrop-filter: blur(10px);
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.viewer-footer span {
|
| 667 |
+
font-size: 0.75rem;
|
| 668 |
+
color: var(--text-dim);
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.viewer-footer a {
|
| 672 |
+
color: var(--text-secondary);
|
| 673 |
+
text-decoration: none;
|
| 674 |
+
transition: color 0.2s ease;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.viewer-footer a:hover {
|
| 678 |
+
color: var(--text-primary);
|
| 679 |
+
text-decoration: underline;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.back-btn {
|
| 683 |
+
display: flex;
|
| 684 |
+
align-items: center;
|
| 685 |
+
gap: 0.5rem;
|
| 686 |
+
padding: 0.75rem 1.25rem;
|
| 687 |
+
font-size: 0.875rem;
|
| 688 |
+
font-weight: 500;
|
| 689 |
+
font-family: inherit;
|
| 690 |
+
color: var(--text-muted);
|
| 691 |
+
background: transparent;
|
| 692 |
+
border: 1px solid var(--card-border);
|
| 693 |
+
border-radius: 10px;
|
| 694 |
+
cursor: pointer;
|
| 695 |
+
transition: all 0.2s ease;
|
| 696 |
+
margin-bottom: 1rem;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.back-btn:hover {
|
| 700 |
+
color: var(--text-secondary);
|
| 701 |
+
border-color: var(--text-dim);
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
.back-btn svg {
|
| 705 |
+
width: 18px;
|
| 706 |
+
height: 18px;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
/* Hide card when viewing */
|
| 710 |
+
.card.hidden {
|
| 711 |
+
display: none;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.header.minimized {
|
| 715 |
+
margin-bottom: 1rem;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.header.minimized .logo {
|
| 719 |
+
font-size: 2rem;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.header.minimized .tagline {
|
| 723 |
+
display: none;
|
| 724 |
+
}
|
src/sharp/web/static/js/main.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Global state for PLY data (on window so module script can access)
|
| 2 |
+
window.currentPlyData = null;
|
| 3 |
+
window.currentPlyFilename = null;
|
| 4 |
+
|
| 5 |
+
// Particle system for background
|
| 6 |
+
const canvas = document.getElementById('particleCanvas');
|
| 7 |
+
const ctx = canvas.getContext('2d');
|
| 8 |
+
let particles = [];
|
| 9 |
+
let animationId;
|
| 10 |
+
let mouseX = 0, mouseY = 0;
|
| 11 |
+
|
| 12 |
+
function resizeCanvas() {
|
| 13 |
+
canvas.width = window.innerWidth;
|
| 14 |
+
canvas.height = window.innerHeight;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
class Particle {
|
| 18 |
+
constructor() {
|
| 19 |
+
this.reset();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
reset() {
|
| 23 |
+
this.x = Math.random() * canvas.width;
|
| 24 |
+
this.y = Math.random() * canvas.height;
|
| 25 |
+
this.z = Math.random() * 1000;
|
| 26 |
+
this.baseSize = Math.random() * 2 + 0.5;
|
| 27 |
+
this.color = this.getColor();
|
| 28 |
+
this.vx = (Math.random() - 0.5) * 0.3;
|
| 29 |
+
this.vy = (Math.random() - 0.5) * 0.3;
|
| 30 |
+
this.vz = (Math.random() - 0.5) * 2;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
getColor() {
|
| 34 |
+
const colors = [
|
| 35 |
+
{ r: 99, g: 102, b: 241 }, // indigo
|
| 36 |
+
{ r: 168, g: 85, b: 247 }, // purple
|
| 37 |
+
{ r: 236, g: 72, b: 153 }, // pink
|
| 38 |
+
{ r: 59, g: 130, b: 246 }, // blue
|
| 39 |
+
{ r: 139, g: 92, b: 246 }, // violet
|
| 40 |
+
];
|
| 41 |
+
return colors[Math.floor(Math.random() * colors.length)];
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
update() {
|
| 45 |
+
// Mouse interaction
|
| 46 |
+
const dx = mouseX - this.x;
|
| 47 |
+
const dy = mouseY - this.y;
|
| 48 |
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 49 |
+
if (dist < 150) {
|
| 50 |
+
const force = (150 - dist) / 150;
|
| 51 |
+
this.vx -= (dx / dist) * force * 0.5;
|
| 52 |
+
this.vy -= (dy / dist) * force * 0.5;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
this.x += this.vx;
|
| 56 |
+
this.y += this.vy;
|
| 57 |
+
this.z += this.vz;
|
| 58 |
+
|
| 59 |
+
// Damping
|
| 60 |
+
this.vx *= 0.99;
|
| 61 |
+
this.vy *= 0.99;
|
| 62 |
+
|
| 63 |
+
// Wrap around
|
| 64 |
+
if (this.x < 0) this.x = canvas.width;
|
| 65 |
+
if (this.x > canvas.width) this.x = 0;
|
| 66 |
+
if (this.y < 0) this.y = canvas.height;
|
| 67 |
+
if (this.y > canvas.height) this.y = 0;
|
| 68 |
+
if (this.z < 0 || this.z > 1000) this.vz *= -1;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
draw() {
|
| 72 |
+
const perspective = 1000 / (1000 + this.z);
|
| 73 |
+
const size = this.baseSize * perspective * 3;
|
| 74 |
+
const isLightMode = document.documentElement.getAttribute('data-theme') === 'light';
|
| 75 |
+
const alpha = perspective * (isLightMode ? 0.4 : 0.6);
|
| 76 |
+
|
| 77 |
+
ctx.beginPath();
|
| 78 |
+
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, size * 2);
|
| 79 |
+
gradient.addColorStop(0, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`);
|
| 80 |
+
gradient.addColorStop(1, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, 0)`);
|
| 81 |
+
ctx.fillStyle = gradient;
|
| 82 |
+
ctx.arc(this.x, this.y, size * 2, 0, Math.PI * 2);
|
| 83 |
+
ctx.fill();
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function initParticles() {
|
| 88 |
+
particles = [];
|
| 89 |
+
const count = Math.min(200, Math.floor((canvas.width * canvas.height) / 8000));
|
| 90 |
+
for (let i = 0; i < count; i++) {
|
| 91 |
+
particles.push(new Particle());
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function animate() {
|
| 96 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 97 |
+
|
| 98 |
+
particles.forEach(p => {
|
| 99 |
+
p.update();
|
| 100 |
+
p.draw();
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// Draw connections
|
| 104 |
+
const isLightMode = document.documentElement.getAttribute('data-theme') === 'light';
|
| 105 |
+
ctx.strokeStyle = isLightMode ? 'rgba(99, 102, 241, 0.08)' : 'rgba(255, 255, 255, 0.02)';
|
| 106 |
+
ctx.lineWidth = 0.5;
|
| 107 |
+
for (let i = 0; i < particles.length; i++) {
|
| 108 |
+
for (let j = i + 1; j < particles.length; j++) {
|
| 109 |
+
const dx = particles[i].x - particles[j].x;
|
| 110 |
+
const dy = particles[i].y - particles[j].y;
|
| 111 |
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 112 |
+
if (dist < 100) {
|
| 113 |
+
ctx.beginPath();
|
| 114 |
+
ctx.moveTo(particles[i].x, particles[i].y);
|
| 115 |
+
ctx.lineTo(particles[j].x, particles[j].y);
|
| 116 |
+
ctx.stroke();
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
animationId = requestAnimationFrame(animate);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
window.addEventListener('resize', () => {
|
| 125 |
+
resizeCanvas();
|
| 126 |
+
initParticles();
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
document.addEventListener('mousemove', (e) => {
|
| 130 |
+
mouseX = e.clientX;
|
| 131 |
+
mouseY = e.clientY;
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
resizeCanvas();
|
| 135 |
+
initParticles();
|
| 136 |
+
animate();
|
| 137 |
+
|
| 138 |
+
// Theme toggle functionality
|
| 139 |
+
const themeToggle = document.getElementById('themeToggle');
|
| 140 |
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
| 141 |
+
|
| 142 |
+
function setTheme(theme) {
|
| 143 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 144 |
+
localStorage.setItem('theme', theme);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function getPreferredTheme() {
|
| 148 |
+
const stored = localStorage.getItem('theme');
|
| 149 |
+
if (stored) return stored;
|
| 150 |
+
return prefersDark.matches ? 'dark' : 'light';
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Initialize theme
|
| 154 |
+
setTheme(getPreferredTheme());
|
| 155 |
+
|
| 156 |
+
themeToggle.addEventListener('click', () => {
|
| 157 |
+
const current = document.documentElement.getAttribute('data-theme');
|
| 158 |
+
setTheme(current === 'light' ? 'dark' : 'light');
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// Listen for system theme changes
|
| 162 |
+
prefersDark.addEventListener('change', (e) => {
|
| 163 |
+
if (!localStorage.getItem('theme')) {
|
| 164 |
+
setTheme(e.matches ? 'dark' : 'light');
|
| 165 |
+
}
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// Upload functionality
|
| 169 |
+
const dropZone = document.getElementById('dropZone');
|
| 170 |
+
const fileInput = document.getElementById('fileInput');
|
| 171 |
+
const fileList = document.getElementById('fileList');
|
| 172 |
+
const form = document.getElementById('uploadForm');
|
| 173 |
+
const loaderContainer = document.getElementById('loaderContainer');
|
| 174 |
+
const results = document.getElementById('results');
|
| 175 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 176 |
+
|
| 177 |
+
dropZone.addEventListener('click', () => fileInput.click());
|
| 178 |
+
|
| 179 |
+
dropZone.addEventListener('dragover', (e) => {
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
dropZone.classList.add('drag-over');
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
dropZone.addEventListener('dragleave', () => {
|
| 185 |
+
dropZone.classList.remove('drag-over');
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
dropZone.addEventListener('drop', (e) => {
|
| 189 |
+
e.preventDefault();
|
| 190 |
+
dropZone.classList.remove('drag-over');
|
| 191 |
+
fileInput.files = e.dataTransfer.files;
|
| 192 |
+
updateFileList();
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
fileInput.addEventListener('change', updateFileList);
|
| 196 |
+
|
| 197 |
+
function formatFileSize(bytes) {
|
| 198 |
+
if (bytes < 1024) return bytes + ' B';
|
| 199 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
| 200 |
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function updateFileList() {
|
| 204 |
+
fileList.innerHTML = '';
|
| 205 |
+
|
| 206 |
+
if (fileInput.files.length > 0) {
|
| 207 |
+
dropZone.classList.add('has-files');
|
| 208 |
+
} else {
|
| 209 |
+
dropZone.classList.remove('has-files');
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
for (const file of fileInput.files) {
|
| 213 |
+
const div = document.createElement('div');
|
| 214 |
+
div.className = 'file-item';
|
| 215 |
+
|
| 216 |
+
// Create preview
|
| 217 |
+
const reader = new FileReader();
|
| 218 |
+
reader.onload = (e) => {
|
| 219 |
+
const preview = div.querySelector('.file-preview');
|
| 220 |
+
if (preview) preview.src = e.target.result;
|
| 221 |
+
};
|
| 222 |
+
reader.readAsDataURL(file);
|
| 223 |
+
|
| 224 |
+
div.innerHTML = `
|
| 225 |
+
<img class="file-preview" src="" alt="">
|
| 226 |
+
<div class="file-info">
|
| 227 |
+
<div class="file-name">${file.name}</div>
|
| 228 |
+
<div class="file-size">${formatFileSize(file.size)}</div>
|
| 229 |
+
</div>
|
| 230 |
+
`;
|
| 231 |
+
fileList.appendChild(div);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
form.addEventListener('submit', async (e) => {
|
| 236 |
+
e.preventDefault();
|
| 237 |
+
if (fileInput.files.length === 0) return;
|
| 238 |
+
|
| 239 |
+
submitBtn.disabled = true;
|
| 240 |
+
dropZone.style.display = 'none';
|
| 241 |
+
fileList.style.display = 'none';
|
| 242 |
+
submitBtn.style.display = 'none';
|
| 243 |
+
loaderContainer.classList.add('active');
|
| 244 |
+
results.innerHTML = '';
|
| 245 |
+
|
| 246 |
+
const formData = new FormData();
|
| 247 |
+
for (const file of fileInput.files) {
|
| 248 |
+
formData.append('files', file);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
try {
|
| 252 |
+
const response = await fetch('/predict', {
|
| 253 |
+
method: 'POST',
|
| 254 |
+
body: formData
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
if (response.ok) {
|
| 258 |
+
const data = await response.json();
|
| 259 |
+
|
| 260 |
+
if (data.results && data.results.length > 0) {
|
| 261 |
+
const result = data.results[0];
|
| 262 |
+
|
| 263 |
+
if (result.error) {
|
| 264 |
+
showError(result.error);
|
| 265 |
+
} else {
|
| 266 |
+
// Store the PLY data and show the viewer
|
| 267 |
+
window.currentPlyData = result.ply_data;
|
| 268 |
+
window.currentPlyFilename = result.ply_filename;
|
| 269 |
+
|
| 270 |
+
// Show viewer (check if module loaded)
|
| 271 |
+
if (typeof window.showViewer === 'function') {
|
| 272 |
+
window.showViewer(result);
|
| 273 |
+
} else {
|
| 274 |
+
// Fallback: offer download if viewer module failed to load
|
| 275 |
+
showError('3D viewer failed to load. Click the download button to get your PLY file.');
|
| 276 |
+
// Trigger download
|
| 277 |
+
downloadPly(result.ply_data, result.ply_filename);
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
} else {
|
| 282 |
+
const error = await response.text();
|
| 283 |
+
showError(error);
|
| 284 |
+
// Restore upload UI on error
|
| 285 |
+
dropZone.style.display = '';
|
| 286 |
+
fileList.style.display = '';
|
| 287 |
+
submitBtn.style.display = '';
|
| 288 |
+
}
|
| 289 |
+
} catch (err) {
|
| 290 |
+
showError(err.message);
|
| 291 |
+
// Restore upload UI on error
|
| 292 |
+
dropZone.style.display = '';
|
| 293 |
+
fileList.style.display = '';
|
| 294 |
+
submitBtn.style.display = '';
|
| 295 |
+
} finally {
|
| 296 |
+
submitBtn.disabled = false;
|
| 297 |
+
loaderContainer.classList.remove('active');
|
| 298 |
+
}
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
function showError(message) {
|
| 302 |
+
results.innerHTML = `
|
| 303 |
+
<div class="result-item" style="background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2);">
|
| 304 |
+
<div class="success-icon" style="background: rgba(239, 68, 68, 0.2);">
|
| 305 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 306 |
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
| 307 |
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
| 308 |
+
</svg>
|
| 309 |
+
</div>
|
| 310 |
+
<div class="result-text">
|
| 311 |
+
<div class="result-title" style="color: #ef4444;">Error</div>
|
| 312 |
+
<div class="result-desc">${message}</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
`;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function downloadPly(plyData, filename) {
|
| 319 |
+
const binaryString = atob(plyData);
|
| 320 |
+
const bytes = new Uint8Array(binaryString.length);
|
| 321 |
+
for (let i = 0; i < binaryString.length; i++) {
|
| 322 |
+
bytes[i] = binaryString.charCodeAt(i);
|
| 323 |
+
}
|
| 324 |
+
const blob = new Blob([bytes], { type: 'application/octet-stream' });
|
| 325 |
+
const url = URL.createObjectURL(blob);
|
| 326 |
+
const a = document.createElement('a');
|
| 327 |
+
a.href = url;
|
| 328 |
+
a.download = filename;
|
| 329 |
+
a.click();
|
| 330 |
+
URL.revokeObjectURL(url);
|
| 331 |
+
}
|
src/sharp/web/static/js/viewer.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
|
| 2 |
+
import * as THREE from 'three';
|
| 3 |
+
|
| 4 |
+
// Make THREE available globally for the viewer
|
| 5 |
+
window.THREE = THREE;
|
| 6 |
+
window.GaussianSplats3D = GaussianSplats3D;
|
| 7 |
+
|
| 8 |
+
// Viewer state
|
| 9 |
+
let viewer = null;
|
| 10 |
+
let initialCameraPosition = null;
|
| 11 |
+
let initialCameraTarget = null;
|
| 12 |
+
|
| 13 |
+
// DOM elements
|
| 14 |
+
const card = document.querySelector('.card');
|
| 15 |
+
const header = document.querySelector('.header');
|
| 16 |
+
const viewerContainer = document.getElementById('viewerContainer');
|
| 17 |
+
const viewerCanvasContainer = document.getElementById('viewerCanvasContainer');
|
| 18 |
+
const viewerFilename = document.getElementById('viewerFilename');
|
| 19 |
+
const backBtn = document.getElementById('backBtn');
|
| 20 |
+
const resetViewBtn = document.getElementById('resetViewBtn');
|
| 21 |
+
const downloadBtn = document.getElementById('downloadBtn');
|
| 22 |
+
const controlsHint = document.getElementById('controlsHint');
|
| 23 |
+
|
| 24 |
+
// Expose showViewer to global scope
|
| 25 |
+
window.showViewer = async function (result) {
|
| 26 |
+
// Update filename badge
|
| 27 |
+
viewerFilename.textContent = result.ply_filename;
|
| 28 |
+
|
| 29 |
+
// Hide card and show viewer
|
| 30 |
+
card.classList.add('hidden');
|
| 31 |
+
header.classList.add('minimized');
|
| 32 |
+
viewerContainer.classList.add('active');
|
| 33 |
+
|
| 34 |
+
// Decode base64 PLY data
|
| 35 |
+
const binaryString = atob(result.ply_data);
|
| 36 |
+
const bytes = new Uint8Array(binaryString.length);
|
| 37 |
+
for (let i = 0; i < binaryString.length; i++) {
|
| 38 |
+
bytes[i] = binaryString.charCodeAt(i);
|
| 39 |
+
}
|
| 40 |
+
const plyBlob = new Blob([bytes], { type: 'application/octet-stream' });
|
| 41 |
+
const plyUrl = URL.createObjectURL(plyBlob);
|
| 42 |
+
|
| 43 |
+
// Clean up previous viewer if exists
|
| 44 |
+
if (viewer) {
|
| 45 |
+
viewer.dispose();
|
| 46 |
+
viewer = null;
|
| 47 |
+
// Clear the container
|
| 48 |
+
while (viewerCanvasContainer.firstChild) {
|
| 49 |
+
if (viewerCanvasContainer.firstChild.id !== 'controlsHint') {
|
| 50 |
+
viewerCanvasContainer.removeChild(viewerCanvasContainer.firstChild);
|
| 51 |
+
} else {
|
| 52 |
+
break;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Wait for container to be visible and sized
|
| 58 |
+
await new Promise(resolve => setTimeout(resolve, 100));
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
// Create the Gaussian Splat viewer
|
| 62 |
+
viewer = new GaussianSplats3D.Viewer({
|
| 63 |
+
cameraUp: [0, -1, 0],
|
| 64 |
+
initialCameraPosition: [0, 0, -3],
|
| 65 |
+
initialCameraLookAt: [0, 0, 0],
|
| 66 |
+
rootElement: viewerCanvasContainer,
|
| 67 |
+
sharedMemoryForWorkers: false,
|
| 68 |
+
dynamicScene: false,
|
| 69 |
+
sceneRevealMode: GaussianSplats3D.SceneRevealMode.Instant,
|
| 70 |
+
antialiased: true,
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// Load the PLY file - specify format since blob URLs don't have extensions
|
| 74 |
+
await viewer.addSplatScene(plyUrl, {
|
| 75 |
+
splatAlphaRemovalThreshold: 5,
|
| 76 |
+
showLoadingUI: false,
|
| 77 |
+
progressiveLoad: false,
|
| 78 |
+
format: GaussianSplats3D.SceneFormat.Ply,
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
viewer.start();
|
| 82 |
+
|
| 83 |
+
// Store initial camera state for reset
|
| 84 |
+
if (viewer.camera) {
|
| 85 |
+
initialCameraPosition = viewer.camera.position.clone();
|
| 86 |
+
initialCameraTarget = new THREE.Vector3(0, 0, 0);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Hide controls hint after a few seconds
|
| 90 |
+
setTimeout(() => {
|
| 91 |
+
controlsHint.style.opacity = '0';
|
| 92 |
+
}, 5000);
|
| 93 |
+
|
| 94 |
+
// Cleanup blob URL after loading
|
| 95 |
+
URL.revokeObjectURL(plyUrl);
|
| 96 |
+
|
| 97 |
+
} catch (error) {
|
| 98 |
+
console.error('Error loading Gaussian Splat:', error);
|
| 99 |
+
viewerCanvasContainer.innerHTML = `
|
| 100 |
+
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #ef4444; text-align: center; padding: 2rem;">
|
| 101 |
+
<div>
|
| 102 |
+
<p style="font-size: 1.1rem; margin-bottom: 0.5rem;">Failed to load 3D viewer</p>
|
| 103 |
+
<p style="font-size: 0.875rem; opacity: 0.7;">${error.message}</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
`;
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
// Back button handler
|
| 111 |
+
backBtn.addEventListener('click', () => {
|
| 112 |
+
// Clean up viewer
|
| 113 |
+
if (viewer) {
|
| 114 |
+
viewer.dispose();
|
| 115 |
+
viewer = null;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Clear the canvas container except for the hint
|
| 119 |
+
const hint = document.getElementById('controlsHint');
|
| 120 |
+
viewerCanvasContainer.innerHTML = '';
|
| 121 |
+
if (hint) {
|
| 122 |
+
hint.style.opacity = '1';
|
| 123 |
+
viewerCanvasContainer.appendChild(hint);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Restore upload UI elements
|
| 127 |
+
const dropZone = document.getElementById('dropZone');
|
| 128 |
+
const fileList = document.getElementById('fileList');
|
| 129 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 130 |
+
dropZone.style.display = '';
|
| 131 |
+
fileList.style.display = '';
|
| 132 |
+
submitBtn.style.display = '';
|
| 133 |
+
|
| 134 |
+
// Show card and hide viewer
|
| 135 |
+
card.classList.remove('hidden');
|
| 136 |
+
header.classList.remove('minimized');
|
| 137 |
+
viewerContainer.classList.remove('active');
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
// Reset view button handler
|
| 141 |
+
resetViewBtn.addEventListener('click', () => {
|
| 142 |
+
if (viewer && viewer.camera && initialCameraPosition) {
|
| 143 |
+
viewer.camera.position.copy(initialCameraPosition);
|
| 144 |
+
viewer.camera.lookAt(initialCameraTarget);
|
| 145 |
+
if (viewer.controls) {
|
| 146 |
+
viewer.controls.target.copy(initialCameraTarget);
|
| 147 |
+
viewer.controls.update();
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
// Download button handler
|
| 153 |
+
downloadBtn.addEventListener('click', () => {
|
| 154 |
+
if (window.currentPlyData && window.currentPlyFilename) {
|
| 155 |
+
const binaryString = atob(window.currentPlyData);
|
| 156 |
+
const bytes = new Uint8Array(binaryString.length);
|
| 157 |
+
for (let i = 0; i < binaryString.length; i++) {
|
| 158 |
+
bytes[i] = binaryString.charCodeAt(i);
|
| 159 |
+
}
|
| 160 |
+
const blob = new Blob([bytes], { type: 'application/octet-stream' });
|
| 161 |
+
const url = URL.createObjectURL(blob);
|
| 162 |
+
const a = document.createElement('a');
|
| 163 |
+
a.href = url;
|
| 164 |
+
a.download = window.currentPlyFilename;
|
| 165 |
+
a.click();
|
| 166 |
+
URL.revokeObjectURL(url);
|
| 167 |
+
}
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// Handle window resize for the viewer
|
| 171 |
+
window.addEventListener('resize', () => {
|
| 172 |
+
if (viewer && viewerContainer.classList.contains('active')) {
|
| 173 |
+
// The viewer should handle resize automatically
|
| 174 |
+
}
|
| 175 |
+
});
|
src/sharp/web/templates/index.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Sharp</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="{{ url_for('static', path='css/styles.css') }}">
|
| 12 |
+
<script type="importmap">
|
| 13 |
+
{
|
| 14 |
+
"imports": {
|
| 15 |
+
"three": "https://esm.sh/three@0.164.0",
|
| 16 |
+
"@mkkellogg/gaussian-splats-3d": "https://esm.sh/@mkkellogg/gaussian-splats-3d@0.4.6"
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
</script>
|
| 20 |
+
</head>
|
| 21 |
+
|
| 22 |
+
<body>
|
| 23 |
+
<canvas id="particleCanvas"></canvas>
|
| 24 |
+
|
| 25 |
+
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
|
| 26 |
+
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"
|
| 27 |
+
stroke-linejoin="round">
|
| 28 |
+
<circle cx="12" cy="12" r="5" />
|
| 29 |
+
<line x1="12" y1="1" x2="12" y2="3" />
|
| 30 |
+
<line x1="12" y1="21" x2="12" y2="23" />
|
| 31 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
| 32 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
| 33 |
+
<line x1="1" y1="12" x2="3" y2="12" />
|
| 34 |
+
<line x1="21" y1="12" x2="23" y2="12" />
|
| 35 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
| 36 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
| 37 |
+
</svg>
|
| 38 |
+
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"
|
| 39 |
+
stroke-linejoin="round">
|
| 40 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
| 41 |
+
</svg>
|
| 42 |
+
</button>
|
| 43 |
+
|
| 44 |
+
<div class="app-container">
|
| 45 |
+
<header class="header">
|
| 46 |
+
<h1 class="logo">Sharp</h1>
|
| 47 |
+
<p class="tagline">Transform images into Gaussian Splats</p>
|
| 48 |
+
</header>
|
| 49 |
+
|
| 50 |
+
<div class="card">
|
| 51 |
+
<form id="uploadForm">
|
| 52 |
+
<div class="upload-zone" id="dropZone">
|
| 53 |
+
<div class="upload-icon">
|
| 54 |
+
<svg viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round"
|
| 55 |
+
stroke-linejoin="round">
|
| 56 |
+
<path d="M12 16V4m0 0l-4 4m4-4l4 4" />
|
| 57 |
+
<path d="M3 20h18" />
|
| 58 |
+
</svg>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="upload-text">
|
| 61 |
+
<h3>Drop your image here</h3>
|
| 62 |
+
<p>or click to browse</p>
|
| 63 |
+
</div>
|
| 64 |
+
<input type="file" id="fileInput" name="files" accept="image/*" style="display: none">
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="file-list" id="fileList"></div>
|
| 68 |
+
|
| 69 |
+
<button type="submit" class="submit-btn" id="submitBtn">
|
| 70 |
+
<span>
|
| 71 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 72 |
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 73 |
+
<path d="M12 3v18M3 12l9 9 9-9" />
|
| 74 |
+
</svg>
|
| 75 |
+
Generate
|
| 76 |
+
</span>
|
| 77 |
+
</button>
|
| 78 |
+
</form>
|
| 79 |
+
|
| 80 |
+
<div class="loader-container" id="loaderContainer">
|
| 81 |
+
<div class="loader"></div>
|
| 82 |
+
<p class="loader-text">Generating Gaussian Splats...</p>
|
| 83 |
+
<p class="loader-subtext">This may take a moment</p>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="results" id="results"></div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<!-- 3D Viewer Container -->
|
| 90 |
+
<div class="viewer-container" id="viewerContainer">
|
| 91 |
+
<button class="back-btn" id="backBtn">
|
| 92 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
| 93 |
+
stroke-linejoin="round">
|
| 94 |
+
<path d="M19 12H5M12 19l-7-7 7-7" />
|
| 95 |
+
</svg>
|
| 96 |
+
Back to Upload
|
| 97 |
+
</button>
|
| 98 |
+
|
| 99 |
+
<div class="viewer-header">
|
| 100 |
+
<div class="viewer-title">
|
| 101 |
+
<h3>3D Gaussian Splat Viewer</h3>
|
| 102 |
+
<span class="file-badge" id="viewerFilename">output.ply</span>
|
| 103 |
+
</div>
|
| 104 |
+
<div class="viewer-actions">
|
| 105 |
+
<button class="viewer-btn" id="resetViewBtn">
|
| 106 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
| 107 |
+
stroke-linecap="round" stroke-linejoin="round">
|
| 108 |
+
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
| 109 |
+
<path d="M3 3v5h5" />
|
| 110 |
+
</svg>
|
| 111 |
+
Reset View
|
| 112 |
+
</button>
|
| 113 |
+
<button class="viewer-btn primary" id="downloadBtn">
|
| 114 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
| 115 |
+
stroke-linecap="round" stroke-linejoin="round">
|
| 116 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
| 117 |
+
<polyline points="7 10 12 15 17 10" />
|
| 118 |
+
<line x1="12" y1="15" x2="12" y2="3" />
|
| 119 |
+
</svg>
|
| 120 |
+
Download PLY
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="viewer-canvas-container" id="viewerCanvasContainer">
|
| 126 |
+
<div class="viewer-controls-hint" id="controlsHint">
|
| 127 |
+
π±οΈ Left click + drag to rotate β’ Scroll to zoom β’ Right click + drag to pan
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<div class="viewer-footer">
|
| 132 |
+
<span>Powered by <a href="https://threejs.org/" target="_blank" rel="noopener">Three.js</a> & <a
|
| 133 |
+
href="https://github.com/mkkellogg/GaussianSplats3D" target="_blank" rel="noopener">Gaussian
|
| 134 |
+
Splats 3D</a></span>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<footer class="footer">
|
| 139 |
+
<p><a href="https://apple.github.io/ml-sharp/" target="_blank" rel="noopener">Mescheder et al., 2025</a> β’
|
| 140 |
+
Sharp: Monocular View Synthesis in Less Than a Second</p>
|
| 141 |
+
</footer>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<!-- Main application script -->
|
| 145 |
+
<script src="{{ url_for('static', path='js/main.js') }}"></script>
|
| 146 |
+
|
| 147 |
+
<!-- 3D Gaussian Splat Viewer Module -->
|
| 148 |
+
<script type="module" src="{{ url_for('static', path='js/viewer.js') }}"></script>
|
| 149 |
+
</body>
|
| 150 |
+
|
| 151 |
+
</html>
|
start.command
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Sharp Web Interface - One-Click Launcher
|
| 3 |
+
# Double-click this file to start the Sharp web interface
|
| 4 |
+
|
| 5 |
+
# Change to the script's directory
|
| 6 |
+
cd "$(dirname "$0")"
|
| 7 |
+
|
| 8 |
+
ENV_NAME="sharp"
|
| 9 |
+
PYTHON_VERSION="3.13"
|
| 10 |
+
|
| 11 |
+
echo "======================================"
|
| 12 |
+
echo " Sharp 3D Prediction - Web Interface"
|
| 13 |
+
echo "======================================"
|
| 14 |
+
echo ""
|
| 15 |
+
|
| 16 |
+
# Check if conda is available
|
| 17 |
+
if ! command -v conda &> /dev/null; then
|
| 18 |
+
echo "β Conda is not installed or not in PATH."
|
| 19 |
+
echo ""
|
| 20 |
+
echo "Please install Miniconda or Anaconda first:"
|
| 21 |
+
echo " https://docs.conda.io/en/latest/miniconda.html"
|
| 22 |
+
echo ""
|
| 23 |
+
read -p "Press Enter to exit..."
|
| 24 |
+
exit 1
|
| 25 |
+
fi
|
| 26 |
+
|
| 27 |
+
# Initialize conda for this shell session
|
| 28 |
+
eval "$(conda shell.bash hook)"
|
| 29 |
+
|
| 30 |
+
# Check if the environment exists
|
| 31 |
+
if ! conda env list | grep -q "^${ENV_NAME} "; then
|
| 32 |
+
echo "π¦ Creating conda environment '${ENV_NAME}' with Python ${PYTHON_VERSION}..."
|
| 33 |
+
conda create -n "$ENV_NAME" python="$PYTHON_VERSION" -y
|
| 34 |
+
if [ $? -ne 0 ]; then
|
| 35 |
+
echo "β Failed to create conda environment."
|
| 36 |
+
read -p "Press Enter to exit..."
|
| 37 |
+
exit 1
|
| 38 |
+
fi
|
| 39 |
+
echo "β
Environment created."
|
| 40 |
+
echo ""
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
+
# Activate the environment
|
| 44 |
+
echo "π Activating conda environment '${ENV_NAME}'..."
|
| 45 |
+
conda activate "$ENV_NAME"
|
| 46 |
+
if [ $? -ne 0 ]; then
|
| 47 |
+
echo "β Failed to activate conda environment."
|
| 48 |
+
read -p "Press Enter to exit..."
|
| 49 |
+
exit 1
|
| 50 |
+
fi
|
| 51 |
+
|
| 52 |
+
# Check if sharp is installed by trying to import it
|
| 53 |
+
echo "π Checking if dependencies are installed..."
|
| 54 |
+
if ! python -c "import sharp" 2>/dev/null; then
|
| 55 |
+
echo "π¦ Installing project dependencies (this may take a few minutes)..."
|
| 56 |
+
pip install -r requirements.txt
|
| 57 |
+
if [ $? -ne 0 ]; then
|
| 58 |
+
echo "β Failed to install requirements."
|
| 59 |
+
read -p "Press Enter to exit..."
|
| 60 |
+
exit 1
|
| 61 |
+
fi
|
| 62 |
+
echo "β
Dependencies installed."
|
| 63 |
+
echo ""
|
| 64 |
+
fi
|
| 65 |
+
|
| 66 |
+
# Check if web dependencies are installed
|
| 67 |
+
if ! python -c "import fastapi" 2>/dev/null; then
|
| 68 |
+
echo "π¦ Installing web interface dependencies..."
|
| 69 |
+
pip install -r src/sharp/web/requirements.txt
|
| 70 |
+
if [ $? -ne 0 ]; then
|
| 71 |
+
echo "β Failed to install web requirements."
|
| 72 |
+
read -p "Press Enter to exit..."
|
| 73 |
+
exit 1
|
| 74 |
+
fi
|
| 75 |
+
echo "β
Web dependencies installed."
|
| 76 |
+
echo ""
|
| 77 |
+
fi
|
| 78 |
+
|
| 79 |
+
echo "======================================"
|
| 80 |
+
echo "π Starting Sharp Web Interface..."
|
| 81 |
+
echo "======================================"
|
| 82 |
+
echo ""
|
| 83 |
+
echo "Open your browser and go to:"
|
| 84 |
+
echo ""
|
| 85 |
+
echo " π http://localhost:8000"
|
| 86 |
+
echo ""
|
| 87 |
+
echo "Press Ctrl+C to stop the server."
|
| 88 |
+
echo ""
|
| 89 |
+
|
| 90 |
+
# Start the web server
|
| 91 |
+
python src/sharp/web/app.py
|
| 92 |
+
|
| 93 |
+
# Keep terminal open if server stops unexpectedly
|
| 94 |
+
read -p "Press Enter to exit..."
|