Spaces:
Running
Running
GitHub Actions Bot commited on
Commit ·
dce7eca
0
Parent(s):
Sync: Thu Feb 12 07:00:42 UTC 2026
Browse files- .env.example +9 -0
- .gitattributes +10 -0
- .github/workflows/keepalive_hf_spaces.yml +41 -0
- .github/workflows/sync_to_hf.yml +53 -0
- .gitignore +40 -0
- Dockerfile +34 -0
- LICENSE +21 -0
- README.md +77 -0
- jest.config.js +27 -0
- jest.setup.js +46 -0
- next.config.ts +10 -0
- package-lock.json +0 -0
- package.json +50 -0
- packages.txt +3 -0
- postcss.config.mjs +8 -0
- requirements.txt +8 -0
- scripts/check-stability-balance.js +58 -0
- scripts/processor.py +116 -0
- scripts/test-keys.js +60 -0
- src/app/api/auth/[...nextauth]/route.ts +6 -0
- src/app/api/generate/route.ts +117 -0
- src/app/globals.css +64 -0
- src/app/layout.tsx +42 -0
- src/app/page.tsx +203 -0
- src/components/AuthProvider.tsx +8 -0
- src/components/Navbar.tsx +75 -0
- src/components/PanoramaViewer.tsx +89 -0
- src/components/UploadSection.tsx +118 -0
- src/lib/auth.ts +23 -0
- src/lib/gemini.ts +36 -0
- src/tests/Navbar.test.tsx +56 -0
- src/tests/PanoramaViewer.test.tsx +11 -0
- src/tests/UploadSection.test.tsx +27 -0
- src/tests/api.test.ts +108 -0
- src/tests/gemini.test.ts +77 -0
- src/tests/page.test.tsx +87 -0
- src/types/index.ts +15 -0
- src/types/three.d.ts +16 -0
- tailwind.config.ts +35 -0
- tasks/panno-ai-api.md +152 -0
- test_output.txt +0 -0
- test_output_v2.txt +58 -0
- tests/test_api_local.py +11 -0
- tsconfig.json +44 -0
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Panno-AI-API Config
|
| 2 |
+
GOOGLE_API_KEY=your_gemini_api_key_here
|
| 3 |
+
AUTH_TOKEN=your_secure_random_token_here
|
| 4 |
+
|
| 5 |
+
# Optional: For GitHub Actions testing
|
| 6 |
+
HF_SPACES_API_URL=https://nzlouislu-panno-ai-api.hf.space
|
| 7 |
+
HF_TOKEN=your_huggingface_write_token
|
| 8 |
+
HF_USERNAME=NZLouislu
|
| 9 |
+
HF_SPACE_NAME=Panno-AI-API
|
.gitattributes
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/keepalive_hf_spaces.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Keep HF Spaces Alive
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
schedule:
|
| 5 |
+
- cron: "*/15 * * * *" # Every 15 minutes
|
| 6 |
+
|
| 7 |
+
workflow_dispatch: # Allow manual trigger
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
ping-api:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
timeout-minutes: 5
|
| 13 |
+
|
| 14 |
+
steps:
|
| 15 |
+
- name: Ping HF Spaces API
|
| 16 |
+
env:
|
| 17 |
+
HF_SPACES_URL: ${{ secrets.HF_SPACES_API_URL }}
|
| 18 |
+
run: |
|
| 19 |
+
if [ -z "$HF_SPACES_URL" ]; then
|
| 20 |
+
echo "❌ Error: HF_SPACES_API_URL secret is not set."
|
| 21 |
+
exit 1
|
| 22 |
+
fi
|
| 23 |
+
|
| 24 |
+
echo "🔔 Pinging: $HF_SPACES_URL/health"
|
| 25 |
+
|
| 26 |
+
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$HF_SPACES_URL/health")
|
| 27 |
+
|
| 28 |
+
if [ "$RESPONSE" -eq 200 ]; then
|
| 29 |
+
echo "✅ API is healthy! (Status: $RESPONSE)"
|
| 30 |
+
else
|
| 31 |
+
echo "⚠️ API returned unexpected status code: $RESPONSE"
|
| 32 |
+
# Try to ping root if health fails
|
| 33 |
+
echo "🔔 Retrying with root URL: $HF_SPACES_URL/"
|
| 34 |
+
ROOT_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$HF_SPACES_URL/")
|
| 35 |
+
if [ "$ROOT_RESPONSE" -eq 200 ]; then
|
| 36 |
+
echo "✅ Root API is responding! (Status: $ROOT_RESPONSE)"
|
| 37 |
+
else
|
| 38 |
+
echo "❌ All ping attempts failed. (Root Status: $ROOT_RESPONSE)"
|
| 39 |
+
exit 1
|
| 40 |
+
fi
|
| 41 |
+
fi
|
.github/workflows/sync_to_hf.yml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
sync-to-hub:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout code
|
| 14 |
+
uses: actions/checkout@v4
|
| 15 |
+
with:
|
| 16 |
+
fetch-depth: 0
|
| 17 |
+
lfs: true
|
| 18 |
+
|
| 19 |
+
- name: Push to Hugging Face Space
|
| 20 |
+
env:
|
| 21 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 22 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 23 |
+
SPACE_NAME: ${{ secrets.HF_SPACE_NAME }}
|
| 24 |
+
run: |
|
| 25 |
+
git config --global user.email "bot@github.com"
|
| 26 |
+
git config --global user.name "GitHub Actions Bot"
|
| 27 |
+
|
| 28 |
+
if [ -z "$HF_TOKEN" ] || [ -z "$HF_USERNAME" ] || [ -z "$SPACE_NAME" ]; then
|
| 29 |
+
echo "Error: Missing required secrets"
|
| 30 |
+
echo "Please set HF_TOKEN, HF_USERNAME, and HF_SPACE_NAME in repository secrets"
|
| 31 |
+
exit 1
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
git remote add space https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/$HF_USERNAME/$SPACE_NAME || true
|
| 35 |
+
|
| 36 |
+
# Create a temporary orphan branch to squash history
|
| 37 |
+
# This avoids pushing historical binary blobs that HF rejects
|
| 38 |
+
git checkout --orphan sync-branch
|
| 39 |
+
git lfs install
|
| 40 |
+
git add -A
|
| 41 |
+
git commit -m "Sync: $(date)"
|
| 42 |
+
|
| 43 |
+
# Push to Hugging Face
|
| 44 |
+
git push --force space sync-branch:main
|
| 45 |
+
|
| 46 |
+
- name: Verify deployment
|
| 47 |
+
env:
|
| 48 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 49 |
+
SPACE_NAME: ${{ secrets.HF_SPACE_NAME }}
|
| 50 |
+
run: |
|
| 51 |
+
echo "Deployment completed!"
|
| 52 |
+
echo "Check your Space at: https://huggingface.co/spaces/$HF_USERNAME/$SPACE_NAME"
|
| 53 |
+
echo "API endpoint: https://$HF_USERNAME-$SPACE_NAME.hf.space"
|
.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dependencies
|
| 2 |
+
/node_modules
|
| 3 |
+
/.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# testing
|
| 7 |
+
/coverage
|
| 8 |
+
|
| 9 |
+
# next.js
|
| 10 |
+
/.next/
|
| 11 |
+
/out/
|
| 12 |
+
|
| 13 |
+
# production
|
| 14 |
+
/build
|
| 15 |
+
|
| 16 |
+
# misc
|
| 17 |
+
.DS_Store
|
| 18 |
+
*.pem
|
| 19 |
+
|
| 20 |
+
# debug
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
| 24 |
+
*.log
|
| 25 |
+
/logs
|
| 26 |
+
/tasks
|
| 27 |
+
|
| 28 |
+
# local env files
|
| 29 |
+
.env*.local
|
| 30 |
+
.env
|
| 31 |
+
.env.development
|
| 32 |
+
.env.production
|
| 33 |
+
.env.test
|
| 34 |
+
|
| 35 |
+
# vercel
|
| 36 |
+
.vercel
|
| 37 |
+
|
| 38 |
+
# typescript
|
| 39 |
+
*.tsbuildinfo
|
| 40 |
+
next-env.d.ts
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-environment Dockerfile for Panno-AI (Next.js + Python/OpenCV)
|
| 2 |
+
FROM node:18-slim
|
| 3 |
+
|
| 4 |
+
# 1. Install Python, Pip and OpenCV system dependencies
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
python3 \
|
| 7 |
+
python3-pip \
|
| 8 |
+
libgl1-mesa-glx \
|
| 9 |
+
libglib2.0-0 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# 2. Install Python dependencies
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# 3. Install Node.js dependencies
|
| 19 |
+
COPY package*.json ./
|
| 20 |
+
RUN npm install
|
| 21 |
+
|
| 22 |
+
# 4. Copy application code
|
| 23 |
+
COPY . .
|
| 24 |
+
|
| 25 |
+
# 5. Build Next.js application
|
| 26 |
+
ENV NODE_ENV production
|
| 27 |
+
ENV PORT 7860
|
| 28 |
+
RUN npm run build
|
| 29 |
+
|
| 30 |
+
# 6. Expose HF Space default port
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# 7. Start the unified application
|
| 34 |
+
CMD ["npm", "start"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Louis Lu
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Panno-AI-API
|
| 3 |
+
emoji: 📸
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# PannoAI - AI-Powered 3D Panorama Generator
|
| 11 |
+
|
| 12 |
+
PannoAI is a cutting-edge web application that transforms standard indoor photos into immersive 360° panoramas using Google's Gemini 2.x Multimodal AI. Designed for real estate, interior design, and digital twins, it provides a seamless experience from image upload to interactive 3D exploration.
|
| 13 |
+
|
| 14 |
+
## 🚀 Features
|
| 15 |
+
|
| 16 |
+
- **AI-Driven Scene Analysis**: Leverages `gemini-2.5-flash-image` to analyze uploaded photos and identify flooring, lighting, layout, and architectural styles.
|
| 17 |
+
- **Immersive 3D Viewer**: Built with Three.js and React Three Fiber, featuring smooth orbit controls, auto-rotation, and responsive design.
|
| 18 |
+
- **Intelligent Fallback System**: Implements a robust API key rotation mechanism to bypass quota limits and ensuring high availability.
|
| 19 |
+
- **Premium Aesthetics**: A modern, glassmorphic UI built with Next.js 15, Tailwind CSS, and Framer Motion.
|
| 20 |
+
- **Offline Reliability**: Integrated stable fallback textures and local storage history for a consistent user experience.
|
| 21 |
+
|
| 22 |
+
## 🛠️ Tech Stack
|
| 23 |
+
|
| 24 |
+
- **Framework**: [Next.js 15 (App Router)](https://nextjs.org/)
|
| 25 |
+
- **3D Engine**: [Three.js](https://threejs.org/) with [@react-three/fiber](https://github.com/pmndrs/react-three-fiber)
|
| 26 |
+
- **AI Integration**: [Google Gemini API](https://ai.google.dev/)
|
| 27 |
+
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
|
| 28 |
+
- **Animations**: [Framer Motion](https://www.framer.com/motion/)
|
| 29 |
+
- **Icons**: [Lucide React](https://lucide.dev/)
|
| 30 |
+
|
| 31 |
+
## 📦 Getting Started
|
| 32 |
+
|
| 33 |
+
### Prerequisites
|
| 34 |
+
|
| 35 |
+
- Node.js 18.x or later
|
| 36 |
+
- A Google AI (Gemini) API Key
|
| 37 |
+
|
| 38 |
+
### Installation
|
| 39 |
+
|
| 40 |
+
1. **Clone the repository**:
|
| 41 |
+
```bash
|
| 42 |
+
git clone https://github.com/NZLouislu/panno-ai.git
|
| 43 |
+
cd panno-ai
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
2. **Install dependencies**:
|
| 47 |
+
```bash
|
| 48 |
+
npm install
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3. **Configure environment variables**:
|
| 52 |
+
Create a `.env` file in the root directory:
|
| 53 |
+
```env
|
| 54 |
+
Gemini_API=your_primary_api_key
|
| 55 |
+
# Optional: Add multiple keys for rotation
|
| 56 |
+
AULouis_Gemini_API=your_backup_key
|
| 57 |
+
Blog_Gemini_API=your_backup_key
|
| 58 |
+
Tasky_Gemini_API=your_backup_key
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
4. **Run the development server**:
|
| 62 |
+
```bash
|
| 63 |
+
npm run dev
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
5. Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 67 |
+
|
| 68 |
+
## 📖 Usage
|
| 69 |
+
|
| 70 |
+
1. **Upload**: Click the upload area or drag and drop indoor photos of a room.
|
| 71 |
+
2. **Generate**: Click "Create 360° Panorama". PannoAI will analyze the images and map them to a high-quality 360° scene.
|
| 72 |
+
3. **Explore**: Use your mouse or touch to look around the generated 3D room.
|
| 73 |
+
4. **History**: Your previous creations are saved and can be accessed from the "Your Gallery" section.
|
| 74 |
+
|
| 75 |
+
## 📄 License
|
| 76 |
+
|
| 77 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
jest.config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const nextJest = require('next/jest')
|
| 2 |
+
|
| 3 |
+
const createJestConfig = nextJest({
|
| 4 |
+
dir: './',
|
| 5 |
+
})
|
| 6 |
+
|
| 7 |
+
const customJestConfig = {
|
| 8 |
+
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
| 9 |
+
testEnvironment: 'jest-environment-jsdom',
|
| 10 |
+
moduleNameMapper: {
|
| 11 |
+
'^@/(.*)$': '<rootDir>/src/$1',
|
| 12 |
+
},
|
| 13 |
+
collectCoverage: true,
|
| 14 |
+
coverageThreshold: {
|
| 15 |
+
global: {
|
| 16 |
+
branches: 30,
|
| 17 |
+
functions: 50,
|
| 18 |
+
lines: 50,
|
| 19 |
+
statements: 50,
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
transformIgnorePatterns: [
|
| 23 |
+
'/node_modules/(?!lucide-react)/'
|
| 24 |
+
],
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
module.exports = createJestConfig(customJestConfig)
|
jest.setup.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import '@testing-library/jest-dom'
|
| 2 |
+
|
| 3 |
+
// Mock global URL.createObjectURL
|
| 4 |
+
global.URL.createObjectURL = jest.fn(() => 'mock-url')
|
| 5 |
+
|
| 6 |
+
// Mock ResizeObserver
|
| 7 |
+
global.ResizeObserver = class {
|
| 8 |
+
observe() { }
|
| 9 |
+
unobserve() { }
|
| 10 |
+
disconnect() { }
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Mock next/image
|
| 14 |
+
jest.mock('next/image', () => ({
|
| 15 |
+
__esModule: true,
|
| 16 |
+
default: (props) => {
|
| 17 |
+
// eslint-disable-next-line @next/next/no-img-element
|
| 18 |
+
return <img {...props} alt={props.alt} />
|
| 19 |
+
},
|
| 20 |
+
}))
|
| 21 |
+
|
| 22 |
+
// Mock lucide-react icons
|
| 23 |
+
jest.mock('lucide-react', () => {
|
| 24 |
+
return new Proxy({}, {
|
| 25 |
+
get: (target, prop) => {
|
| 26 |
+
const Icon = (props) => <div data-testid={`icon-${String(prop)}`} {...props} />
|
| 27 |
+
Icon.displayName = String(prop)
|
| 28 |
+
return Icon
|
| 29 |
+
}
|
| 30 |
+
})
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
// Mock framer-motion
|
| 34 |
+
jest.mock('framer-motion', () => ({
|
| 35 |
+
motion: {
|
| 36 |
+
div: ({ children, ...props }) => <div {...props}>{children}</div>,
|
| 37 |
+
},
|
| 38 |
+
AnimatePresence: ({ children }) => <>{children}</>,
|
| 39 |
+
}))
|
| 40 |
+
// Mock global fetch
|
| 41 |
+
global.fetch = jest.fn(() =>
|
| 42 |
+
Promise.resolve({
|
| 43 |
+
ok: true,
|
| 44 |
+
json: () => Promise.resolve({ success: true, url: 'mock-url' }),
|
| 45 |
+
})
|
| 46 |
+
);
|
next.config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
images: {
|
| 6 |
+
domains: ["lh3.googleusercontent.com"], // For Google profile pics
|
| 7 |
+
},
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "panno-ai",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint",
|
| 10 |
+
"test": "jest",
|
| 11 |
+
"test:coverage": "jest --coverage"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@google/generative-ai": "^0.21.0",
|
| 15 |
+
"@react-three/drei": "^10.7.7",
|
| 16 |
+
"@react-three/fiber": "^9.5.0",
|
| 17 |
+
"clsx": "^2.1.1",
|
| 18 |
+
"framer-motion": "^12.0.6",
|
| 19 |
+
"lucide-react": "^0.474.0",
|
| 20 |
+
"next": "15.5.12",
|
| 21 |
+
"next-auth": "^4.24.11",
|
| 22 |
+
"react": "^19.0.0",
|
| 23 |
+
"react-dom": "^19.0.0",
|
| 24 |
+
"tailwind-merge": "^2.6.0",
|
| 25 |
+
"three": "^0.173.0"
|
| 26 |
+
},
|
| 27 |
+
"devDependencies": {
|
| 28 |
+
"@testing-library/dom": "^10.4.1",
|
| 29 |
+
"@testing-library/jest-dom": "^6.9.1",
|
| 30 |
+
"@testing-library/react": "^16.3.2",
|
| 31 |
+
"@testing-library/user-event": "^14.6.1",
|
| 32 |
+
"@types/jest": "^30.0.0",
|
| 33 |
+
"@types/node": "^22.13.1",
|
| 34 |
+
"@types/react": "^19.0.8",
|
| 35 |
+
"@types/react-dom": "^19.0.3",
|
| 36 |
+
"@types/three": "^0.182.0",
|
| 37 |
+
"eslint": "^9.19.0",
|
| 38 |
+
"eslint-config-next": "15.1.6",
|
| 39 |
+
"jest": "^30.2.0",
|
| 40 |
+
"jest-environment-jsdom": "^30.2.0",
|
| 41 |
+
"postcss": "^8.5.1",
|
| 42 |
+
"tailwindcss": "^3.4.17",
|
| 43 |
+
"ts-jest": "^29.4.6",
|
| 44 |
+
"typescript": "^5.7.3"
|
| 45 |
+
},
|
| 46 |
+
"overrides": {
|
| 47 |
+
"react": "^19.0.0",
|
| 48 |
+
"react-dom": "^19.0.0"
|
| 49 |
+
}
|
| 50 |
+
}
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libgl1-mesa-glx
|
| 2 |
+
libglib2.0-0
|
| 3 |
+
ffmpeg
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('postcss-load-config').Config} */
|
| 2 |
+
const config = {
|
| 3 |
+
plugins: {
|
| 4 |
+
tailwindcss: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default config;
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
opencv-python-headless
|
| 4 |
+
numpy
|
| 5 |
+
requests
|
| 6 |
+
google-generativeai
|
| 7 |
+
python-multipart
|
| 8 |
+
Pillow
|
scripts/check-stability-balance.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const https = require('https');
|
| 2 |
+
|
| 3 |
+
const keys = [
|
| 4 |
+
'sk-EXpDPvj0PnYh2l5cof3JDGctgYUrWHVN1DjvDxDHi9e7Vq7Z',
|
| 5 |
+
'sk-KSUPdEt40yyHwWkymuCA9w5gefrfgJPha5gH23l5Mjdsn6Hq',
|
| 6 |
+
'sk-xwPk8wHR3hZR9Ya11LnXci0A70N2QxIwVv9gO43VZ5H3QCrN'
|
| 7 |
+
];
|
| 8 |
+
|
| 9 |
+
function checkBalance(key) {
|
| 10 |
+
return new Promise((resolve) => {
|
| 11 |
+
console.log(`\nChecking Key: ${key.substring(0, 10)}...`);
|
| 12 |
+
|
| 13 |
+
const options = {
|
| 14 |
+
hostname: 'api.stability.ai',
|
| 15 |
+
path: '/v1/user/balance',
|
| 16 |
+
method: 'GET',
|
| 17 |
+
headers: {
|
| 18 |
+
Authorization: `Bearer ${key}`
|
| 19 |
+
}
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const req = https.request(options, (res) => {
|
| 23 |
+
let data = '';
|
| 24 |
+
res.on('data', (chunk) => data += chunk);
|
| 25 |
+
res.on('end', () => {
|
| 26 |
+
if (res.statusCode === 200) {
|
| 27 |
+
const json = JSON.parse(data);
|
| 28 |
+
console.log(`✅ Status: OK`);
|
| 29 |
+
console.log(`💰 Credits: ${json.credits}`);
|
| 30 |
+
} else {
|
| 31 |
+
console.log(`❌ Status: ${res.statusCode}`);
|
| 32 |
+
console.log(`📝 Response: ${data}`);
|
| 33 |
+
if (res.statusCode === 402) {
|
| 34 |
+
console.log('⚠️ Result: Out of credits (402 Payment Required)');
|
| 35 |
+
} else if (res.statusCode === 401) {
|
| 36 |
+
console.log('⚠️ Result: Invalid API Key (401 Unauthorized)');
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
resolve();
|
| 40 |
+
});
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
req.on('error', (e) => {
|
| 44 |
+
console.error(`❌ Request Error: ${e.message}`);
|
| 45 |
+
resolve();
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
req.end();
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
async function run() {
|
| 53 |
+
for (const key of keys) {
|
| 54 |
+
await checkBalance(key);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
run();
|
scripts/processor.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import base64
|
| 4 |
+
import requests
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
class PanoEngine:
|
| 10 |
+
def __init__(self, api_key):
|
| 11 |
+
self.api_key = api_key
|
| 12 |
+
# 创建拼接器,全景模式
|
| 13 |
+
self.stitcher = cv2.Stitcher_create(cv2.Stitcher_PANORAMA)
|
| 14 |
+
|
| 15 |
+
def stitch_and_generate_mask(self, image_paths):
|
| 16 |
+
"""1. 使用 OpenCV 拼接并生成掩码"""
|
| 17 |
+
imgs = []
|
| 18 |
+
for p in image_paths:
|
| 19 |
+
img = cv2.imread(p)
|
| 20 |
+
if img is not None:
|
| 21 |
+
imgs.append(img)
|
| 22 |
+
|
| 23 |
+
if len(imgs) < 1:
|
| 24 |
+
raise Exception("No valid images found for stitching.")
|
| 25 |
+
|
| 26 |
+
if len(imgs) == 1:
|
| 27 |
+
# 只有一张图时,直接作为基础图
|
| 28 |
+
pano = imgs[0]
|
| 29 |
+
else:
|
| 30 |
+
# 拼接
|
| 31 |
+
status, pano = self.stitcher.stitch(imgs)
|
| 32 |
+
if status != cv2.Stitcher_OK:
|
| 33 |
+
# 如果拼接失败,回退到第一张图
|
| 34 |
+
pano = imgs[0]
|
| 35 |
+
|
| 36 |
+
# Prepare canvas with strictly 2:1 ratio
|
| 37 |
+
h, w = pano.shape[:2]
|
| 38 |
+
canvas_w = w
|
| 39 |
+
canvas_h = int(w / 2)
|
| 40 |
+
|
| 41 |
+
canvas = np.zeros((canvas_h, canvas_w, 3), dtype=np.uint8)
|
| 42 |
+
|
| 43 |
+
if h > canvas_h:
|
| 44 |
+
# Prevent vertical stretching: Crop center height
|
| 45 |
+
start_y = (h - canvas_h) // 2
|
| 46 |
+
stitched_crop = pano[start_y:start_y+canvas_h, :]
|
| 47 |
+
canvas = stitched_crop
|
| 48 |
+
else:
|
| 49 |
+
# Fill gaps: Center vertically
|
| 50 |
+
y_offset = (canvas_h - h) // 2
|
| 51 |
+
canvas[y_offset:y_offset+h, 0:w] = pano
|
| 52 |
+
|
| 53 |
+
# Create specialized mask for ceiling/floor inpainting
|
| 54 |
+
mask = np.ones((canvas_h, canvas_w), dtype=np.uint8) * 255
|
| 55 |
+
actual_h = min(h, canvas_h)
|
| 56 |
+
actual_y = (canvas_h - actual_h) // 2
|
| 57 |
+
mask[actual_y:actual_y+actual_h, 0:w] = 0
|
| 58 |
+
|
| 59 |
+
return canvas, mask
|
| 60 |
+
|
| 61 |
+
def stability_inpaint(self, image, mask, prompt):
|
| 62 |
+
"""2. Stability AI v2beta Inpaint API"""
|
| 63 |
+
url = "https://api.stability.ai/v2beta/stable-image/edit/inpaint"
|
| 64 |
+
|
| 65 |
+
_, img_encoded = cv2.imencode(".png", image)
|
| 66 |
+
_, mask_encoded = cv2.imencode(".png", mask)
|
| 67 |
+
|
| 68 |
+
headers = {
|
| 69 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 70 |
+
"Accept": "image/*"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
files = {
|
| 74 |
+
"image": ("image.png", img_encoded.tobytes(), "image/png"),
|
| 75 |
+
"mask": ("mask.png", mask_encoded.tobytes(), "image/png"),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
data = {
|
| 79 |
+
"prompt": f"{prompt}, photorealistic 360 panorama, wide angle, immersive view, seamless texture",
|
| 80 |
+
"output_format": "png",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
response = requests.post(url, headers=headers, files=files, data=data)
|
| 84 |
+
|
| 85 |
+
if response.status_code != 200:
|
| 86 |
+
raise Exception(f"Stability API Error ({response.status_code}): {response.text}")
|
| 87 |
+
|
| 88 |
+
result_base64 = base64.b64encode(response.content).decode('utf-8')
|
| 89 |
+
return result_base64
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
if len(sys.argv) < 3:
|
| 94 |
+
print(json.dumps({"success": False, "error": "Insufficient arguments"}))
|
| 95 |
+
sys.exit(1)
|
| 96 |
+
|
| 97 |
+
api_key = sys.argv[1]
|
| 98 |
+
prompt = sys.argv[2]
|
| 99 |
+
img_paths = sys.argv[3:]
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
engine = PanoEngine(api_key)
|
| 103 |
+
# 某些环境可能没有安装 OpenCV 拼接插件,做个兼容
|
| 104 |
+
pano, mask = engine.stitch_and_generate_mask(img_paths)
|
| 105 |
+
|
| 106 |
+
# 如果没有配置 API key,仅返回拼接结果(开发者调试用)
|
| 107 |
+
if not api_key or api_key == "undefined":
|
| 108 |
+
_, final_encoded = cv2.imencode(".png", pano)
|
| 109 |
+
res_base64 = base64.b64encode(final_encoded).decode('utf-8')
|
| 110 |
+
print(json.dumps({"success": True, "image": f"data:image/png;base64,{res_base64}", "note": "Stitched only, no AI"}))
|
| 111 |
+
else:
|
| 112 |
+
res_base64 = engine.stability_inpaint(pano, mask, prompt)
|
| 113 |
+
print(json.dumps({"success": True, "image": f"data:image/png;base64,{res_base64}"}))
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(json.dumps({"success": False, "error": str(e)}))
|
scripts/test-keys.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
| 2 |
+
const fs = require("fs");
|
| 3 |
+
|
| 4 |
+
const logFile = "api_testing_log.txt";
|
| 5 |
+
fs.writeFileSync(logFile, "Starting API Key Tests\n\n");
|
| 6 |
+
|
| 7 |
+
function log(msg) {
|
| 8 |
+
console.log(msg);
|
| 9 |
+
fs.appendFileSync(logFile, msg + "\n");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const keys = [
|
| 13 |
+
process.env.Gemini_API,
|
| 14 |
+
process.env.AULouis_Gemini_API,
|
| 15 |
+
process.env.Blog_Gemini_API,
|
| 16 |
+
process.env.Tasky_Gemini_API,
|
| 17 |
+
process.env.Marie_Gemini_API,
|
| 18 |
+
process.env.AILouis_Gemini_API,
|
| 19 |
+
].filter(Boolean);
|
| 20 |
+
|
| 21 |
+
async function testKey(key, index) {
|
| 22 |
+
log(`\n--- Testing Key ${index}: ${key.substring(0, 10)}... ---`);
|
| 23 |
+
const genAI = new GoogleGenerativeAI(key);
|
| 24 |
+
|
| 25 |
+
// Test 1: Basic Ping with 2.0-flash
|
| 26 |
+
try {
|
| 27 |
+
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
| 28 |
+
const result = await model.generateContent("ping");
|
| 29 |
+
const response = await result.response;
|
| 30 |
+
log(`[Key ${index}] 2.0-flash OK`);
|
| 31 |
+
} catch (error) {
|
| 32 |
+
log(`[Key ${index}] 2.0-flash FAILED: ${error.message}`);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Test 2: Specific Image Generation with 2.5-flash-image
|
| 36 |
+
try {
|
| 37 |
+
const imgModel = genAI.getGenerativeModel({ model: "gemini-2.5-flash-image" });
|
| 38 |
+
const imgResult = await imgModel.generateContent("test");
|
| 39 |
+
const imgResponse = await imgResult.response;
|
| 40 |
+
log(`[Key ${index}] 2.5-flash-image OK`);
|
| 41 |
+
return true;
|
| 42 |
+
} catch (error) {
|
| 43 |
+
log(`[Key ${index}] 2.5-flash-image FAILED: ${error.message}`);
|
| 44 |
+
return false;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function runTests() {
|
| 49 |
+
log(`Found ${keys.length} keys in current environment.`);
|
| 50 |
+
if (keys.length === 0) {
|
| 51 |
+
log("No keys found! Make sure you run with: node --env-file=.env scripts/test-keys.js");
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
for (let i = 0; i < keys.length; i++) {
|
| 55 |
+
await testKey(keys[i], i);
|
| 56 |
+
}
|
| 57 |
+
log("\nTests completed.");
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
runTests();
|
src/app/api/auth/[...nextauth]/route.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import NextAuth from "next-auth";
|
| 2 |
+
import { authOptions } from "@/lib/auth";
|
| 3 |
+
|
| 4 |
+
const handler = NextAuth(authOptions);
|
| 5 |
+
|
| 6 |
+
export { handler as GET, handler as POST };
|
src/app/api/generate/route.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { exec } from "child_process";
|
| 3 |
+
import { promisify } from "util";
|
| 4 |
+
import fs from "fs";
|
| 5 |
+
import path from "path";
|
| 6 |
+
import os from "os";
|
| 7 |
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
| 8 |
+
|
| 9 |
+
const execAsync = promisify(exec);
|
| 10 |
+
|
| 11 |
+
export async function POST(req: NextRequest) {
|
| 12 |
+
const tempFiles: string[] = [];
|
| 13 |
+
try {
|
| 14 |
+
const formData = await req.formData();
|
| 15 |
+
const prompt = formData.get("prompt") as string || "a photographic 360 panorama";
|
| 16 |
+
const images = formData.getAll("images") as File[];
|
| 17 |
+
|
| 18 |
+
// 1. Save uploaded images to temp directory
|
| 19 |
+
const tempDir = path.join(os.tmpdir(), `panno-${Date.now()}`);
|
| 20 |
+
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir);
|
| 21 |
+
|
| 22 |
+
for (let i = 0; i < images.length; i++) {
|
| 23 |
+
const buffer = Buffer.from(await images[i].arrayBuffer());
|
| 24 |
+
const fileName = path.join(tempDir, `img_${i}.png`);
|
| 25 |
+
fs.writeFileSync(fileName, buffer);
|
| 26 |
+
tempFiles.push(fileName);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// 2. Load Config & Keys
|
| 30 |
+
const stabilityKey = process.env.Home_STABILITY_API_KEY || process.env.STABILITY_API_KEY;
|
| 31 |
+
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
| 32 |
+
|
| 33 |
+
if (!stabilityKey) {
|
| 34 |
+
throw new Error("Missing STABILITY_API_KEY in Environment Variables.");
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
let result: { image: string | null, method: string } = { image: null, method: "failed" };
|
| 38 |
+
|
| 39 |
+
// --- CORE LOGIC: Direct Python Local Execution ---
|
| 40 |
+
// Since we are in a unified Docker container on HF, we always run locally.
|
| 41 |
+
try {
|
| 42 |
+
const pythonScript = path.join(process.cwd(), "scripts", "processor.py");
|
| 43 |
+
const imageArgs = tempFiles.map(img => `"${img}"`).join(" ");
|
| 44 |
+
|
| 45 |
+
console.log("Unified Container: Executing specialized Python engine...");
|
| 46 |
+
|
| 47 |
+
// Detect OS and use appropriate python command
|
| 48 |
+
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
| 49 |
+
|
| 50 |
+
const { stdout } = await execAsync(`${pythonCmd} "${pythonScript}" "${stabilityKey}" "${prompt.replace(/"/g, '\\"')}" ${imageArgs}`, {
|
| 51 |
+
maxBuffer: 1024 * 1024 * 20,
|
| 52 |
+
timeout: 120000 // 2 minute limit for heavy processing
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const parsed = JSON.parse(stdout);
|
| 56 |
+
if (parsed.success) {
|
| 57 |
+
result = { image: parsed.image, method: "unified_hf_engine" };
|
| 58 |
+
} else {
|
| 59 |
+
throw new Error(parsed.error || "Stitching engine failed");
|
| 60 |
+
}
|
| 61 |
+
} catch (err: any) {
|
| 62 |
+
console.warn("Local Engine Error, attempting Vision Fallback:", err.message);
|
| 63 |
+
|
| 64 |
+
// --- FALLBACK: Pure AI Cloud (If Python fails) ---
|
| 65 |
+
let visionPrompt = prompt;
|
| 66 |
+
if (geminiKey && tempFiles.length > 0) {
|
| 67 |
+
try {
|
| 68 |
+
const genAI = new GoogleGenerativeAI(geminiKey);
|
| 69 |
+
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
| 70 |
+
const visionResult = await model.generateContent([
|
| 71 |
+
"Describe room type and style in 10 words.",
|
| 72 |
+
{ inlineData: { data: fs.readFileSync(tempFiles[0]).toString("base64"), mimeType: "image/png" } }
|
| 73 |
+
]);
|
| 74 |
+
visionPrompt = `${prompt}. Style: ${visionResult.response.text()}`;
|
| 75 |
+
} catch (e) { }
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const aiFormData = new FormData();
|
| 79 |
+
aiFormData.append("prompt", `${visionPrompt}, 360 panorama, rectilinear, high quality, seamless`);
|
| 80 |
+
aiFormData.append("output_format", "webp");
|
| 81 |
+
aiFormData.append("aspect_ratio", "21:9");
|
| 82 |
+
|
| 83 |
+
const response = await fetch("https://api.stability.ai/v2beta/stable-image/generate/ultra", {
|
| 84 |
+
method: "POST",
|
| 85 |
+
headers: { "Authorization": `Bearer ${stabilityKey}`, "Accept": "application/json" },
|
| 86 |
+
body: aiFormData
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
const data = await response.json();
|
| 90 |
+
if (response.ok && data.image) {
|
| 91 |
+
result = { image: `data:image/webp;base64,${data.image}`, method: "unified_pure_ai_fallback" };
|
| 92 |
+
} else {
|
| 93 |
+
throw new Error(data.message || "All methods failed");
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return NextResponse.json({
|
| 98 |
+
url: result.image,
|
| 99 |
+
success: true,
|
| 100 |
+
method: result.method
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
} catch (error: any) {
|
| 104 |
+
console.error("Unified Pipeline Error:", error.message);
|
| 105 |
+
return NextResponse.json({
|
| 106 |
+
success: false,
|
| 107 |
+
message: error.message || "Process failed"
|
| 108 |
+
}, { status: 500 });
|
| 109 |
+
} finally {
|
| 110 |
+
// Cleanup
|
| 111 |
+
try {
|
| 112 |
+
tempFiles.forEach(f => { if (fs.existsSync(f)) fs.unlinkSync(f); });
|
| 113 |
+
const dir = path.dirname(tempFiles[0]);
|
| 114 |
+
if (dir && fs.existsSync(dir)) fs.rmdirSync(dir);
|
| 115 |
+
} catch (e) { }
|
| 116 |
+
}
|
| 117 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--background: #020617;
|
| 7 |
+
--foreground: #f8fafc;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
body {
|
| 11 |
+
color: var(--foreground);
|
| 12 |
+
background: var(--background);
|
| 13 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 14 |
+
overflow-x: hidden;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
@layer components {
|
| 18 |
+
.glass {
|
| 19 |
+
@apply bg-white/5 backdrop-blur-lg border border-white/10;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.glass-card {
|
| 23 |
+
@apply glass rounded-2xl p-6 shadow-2xl transition-all duration-300 hover:bg-white/10 hover:border-white/20;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.btn-primary {
|
| 27 |
+
@apply bg-gradient-to-r from-primary to-indigo-500 text-white font-medium py-2 px-6 rounded-xl
|
| 28 |
+
transition-all duration-300 hover:scale-[1.02] active:scale-95 shadow-lg shadow-primary/20
|
| 29 |
+
disabled:opacity-50 disabled:cursor-not-allowed;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.input-field {
|
| 33 |
+
@apply bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2
|
| 34 |
+
focus:ring-primary/50 transition-all duration-200 placeholder:text-white/30;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Custom Scrollbar */
|
| 39 |
+
::-webkit-scrollbar {
|
| 40 |
+
width: 8px;
|
| 41 |
+
}
|
| 42 |
+
::-webkit-scrollbar-track {
|
| 43 |
+
background: transparent;
|
| 44 |
+
}
|
| 45 |
+
::-webkit-scrollbar-thumb {
|
| 46 |
+
background: #1e293b;
|
| 47 |
+
border-radius: 4px;
|
| 48 |
+
}
|
| 49 |
+
::-webkit-scrollbar-thumb:hover {
|
| 50 |
+
background: #334155;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.animate-glow {
|
| 54 |
+
animation: bg-glow 8s ease-in-out infinite alternate;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
@keyframes bg-glow {
|
| 58 |
+
0% { opacity: 0.3; }
|
| 59 |
+
100% { opacity: 0.7; }
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.gradient-text {
|
| 63 |
+
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-cyan-400;
|
| 64 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import AuthProvider from "@/components/AuthProvider";
|
| 5 |
+
|
| 6 |
+
const inter = Inter({ subsets: ["latin"] });
|
| 7 |
+
|
| 8 |
+
export const metadata: Metadata = {
|
| 9 |
+
title: "PanoAI - 360° Scene Creator",
|
| 10 |
+
description: "Generate immersive 360-degree panoramas from photos or text using AI.",
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export default function RootLayout({
|
| 14 |
+
children,
|
| 15 |
+
}: Readonly<{
|
| 16 |
+
children: React.ReactNode;
|
| 17 |
+
}>) {
|
| 18 |
+
return (
|
| 19 |
+
<html lang="en" suppressHydrationWarning>
|
| 20 |
+
<head>
|
| 21 |
+
<link
|
| 22 |
+
rel="stylesheet"
|
| 23 |
+
href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"
|
| 24 |
+
/>
|
| 25 |
+
<script
|
| 26 |
+
src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"
|
| 27 |
+
async
|
| 28 |
+
></script>
|
| 29 |
+
</head>
|
| 30 |
+
<body className={`${inter.className} min-h-screen antialiased`} suppressHydrationWarning>
|
| 31 |
+
<AuthProvider>
|
| 32 |
+
|
| 33 |
+
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900 to-black overflow-hidden">
|
| 34 |
+
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/20 blur-[120px] rounded-full animate-glow opacity-30" />
|
| 35 |
+
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-accent/20 blur-[120px] rounded-full animate-glow opacity-30 delay-1000" />
|
| 36 |
+
</div>
|
| 37 |
+
{children}
|
| 38 |
+
</AuthProvider>
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
| 41 |
+
);
|
| 42 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, Suspense } from "react";
|
| 4 |
+
import Navbar from "@/components/Navbar";
|
| 5 |
+
import UploadSection from "@/components/UploadSection";
|
| 6 |
+
import dynamic from "next/dynamic";
|
| 7 |
+
const PanoramaViewer = dynamic(() => import("@/components/PanoramaViewer"), { ssr: false });
|
| 8 |
+
import { History as HistoryIcon, Maximize2, Download, Share2, Layers } from "lucide-react";
|
| 9 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 10 |
+
|
| 11 |
+
interface Panorama {
|
| 12 |
+
id: string;
|
| 13 |
+
url: string;
|
| 14 |
+
prompt: string;
|
| 15 |
+
timestamp: Date;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function Home() {
|
| 19 |
+
const [currentPano, setCurrentPano] = useState<Panorama | null>(null);
|
| 20 |
+
const [history, setHistory] = useState<Panorama[]>([]);
|
| 21 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
const saved = localStorage.getItem("pano-history");
|
| 25 |
+
if (saved) {
|
| 26 |
+
try {
|
| 27 |
+
const parsed = JSON.parse(saved);
|
| 28 |
+
if (Array.isArray(parsed)) {
|
| 29 |
+
// Only keep local or known stable URLs
|
| 30 |
+
const filtered = parsed.filter(item =>
|
| 31 |
+
item.url && (
|
| 32 |
+
item.url.startsWith("/") ||
|
| 33 |
+
item.url.startsWith("data:") ||
|
| 34 |
+
item.url.includes("threejs.org") ||
|
| 35 |
+
item.url.includes("polyhaven.org")
|
| 36 |
+
)
|
| 37 |
+
);
|
| 38 |
+
setHistory(filtered);
|
| 39 |
+
if (filtered.length > 0) setCurrentPano(filtered[0]);
|
| 40 |
+
}
|
| 41 |
+
} catch (e) {
|
| 42 |
+
console.error("Failed to load history");
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (history.length > 0) {
|
| 49 |
+
try {
|
| 50 |
+
// Limit history to last 10 items to save LocalStorage space
|
| 51 |
+
const limitedHistory = history.slice(0, 10);
|
| 52 |
+
localStorage.setItem("pano-history", JSON.stringify(limitedHistory));
|
| 53 |
+
} catch (e) {
|
| 54 |
+
console.warn("Storage quota exceeded, history not fully saved");
|
| 55 |
+
// If quota exceeded, try saving fewer items
|
| 56 |
+
try {
|
| 57 |
+
localStorage.setItem("pano-history", JSON.stringify(history.slice(0, 3)));
|
| 58 |
+
} catch (innerError) {
|
| 59 |
+
console.error("Critical storage failure:", innerError);
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}, [history]);
|
| 64 |
+
|
| 65 |
+
const handleGenerate = async (prompt: string, images: File[]) => {
|
| 66 |
+
setIsGenerating(true);
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const formData = new FormData();
|
| 70 |
+
formData.append("prompt", prompt);
|
| 71 |
+
images.forEach(img => formData.append("images", img));
|
| 72 |
+
|
| 73 |
+
const response = await fetch("/api/generate", {
|
| 74 |
+
method: "POST",
|
| 75 |
+
body: formData,
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
const result = await response.json();
|
| 79 |
+
|
| 80 |
+
if (!response.ok || !result.success) {
|
| 81 |
+
throw new Error(result.message || "Failed to generate panorama");
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const newPano = {
|
| 85 |
+
id: Math.random().toString(36).substr(2, 9),
|
| 86 |
+
url: result.url,
|
| 87 |
+
prompt: prompt || "Generated from photos",
|
| 88 |
+
timestamp: new Date(),
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
setCurrentPano(newPano);
|
| 92 |
+
setHistory(prev => [newPano, ...prev]);
|
| 93 |
+
} catch (error: any) {
|
| 94 |
+
console.error("Generate error:", error);
|
| 95 |
+
alert(`Generation Error: ${error.message}`);
|
| 96 |
+
} finally {
|
| 97 |
+
setIsGenerating(false);
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<main className="min-h-screen flex flex-col">
|
| 103 |
+
<Navbar />
|
| 104 |
+
|
| 105 |
+
<div className="flex-1 container mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 106 |
+
{/* Left Side - Controls */}
|
| 107 |
+
<div className="lg:col-span-4 xl:col-span-3">
|
| 108 |
+
<UploadSection onGenerate={handleGenerate} isGenerating={isGenerating} />
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Right Side - Viewer / Gallery */}
|
| 112 |
+
<div className="lg:col-span-8 xl:col-span-9 flex flex-col gap-6">
|
| 113 |
+
<div className="glass-card flex-1 min-h-[500px] flex flex-col p-4">
|
| 114 |
+
<div className="flex items-center justify-between mb-4 px-2">
|
| 115 |
+
<h3 className="text-xl font-bold flex items-center gap-2">
|
| 116 |
+
<Maximize2 className="w-5 h-5 text-primary" />
|
| 117 |
+
Immersive View
|
| 118 |
+
</h3>
|
| 119 |
+
{currentPano && (
|
| 120 |
+
<div className="flex gap-2">
|
| 121 |
+
<button className="p-2 glass hover:bg-white/10 rounded-lg transition-colors border-white/5">
|
| 122 |
+
<Download className="w-5 h-5" />
|
| 123 |
+
</button>
|
| 124 |
+
<button className="p-2 glass hover:bg-white/10 rounded-lg transition-colors border-white/5">
|
| 125 |
+
<Share2 className="w-5 h-5" />
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<div className="flex-1 relative min-h-[400px]">
|
| 132 |
+
<AnimatePresence mode="wait">
|
| 133 |
+
{currentPano ? (
|
| 134 |
+
<motion.div
|
| 135 |
+
key={currentPano.id}
|
| 136 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 137 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 138 |
+
exit={{ opacity: 0, scale: 1.05 }}
|
| 139 |
+
className="w-full h-full absolute inset-0"
|
| 140 |
+
>
|
| 141 |
+
<Suspense fallback={<div className="w-full h-full bg-slate-900 animate-pulse" />}>
|
| 142 |
+
<PanoramaViewer imageUrl={currentPano.url} />
|
| 143 |
+
</Suspense>
|
| 144 |
+
</motion.div>
|
| 145 |
+
) : (
|
| 146 |
+
<div className="w-full h-full flex flex-col items-center justify-center text-white/20 gap-4 glass rounded-2xl border-white/5 border-dashed border-2">
|
| 147 |
+
<div className="w-20 h-20 bg-white/5 rounded-full flex items-center justify-center animate-pulse">
|
| 148 |
+
<Layers className="w-10 h-10" />
|
| 149 |
+
</div>
|
| 150 |
+
<div className="text-center">
|
| 151 |
+
<p className="text-xl font-semibold">No panoramas yet</p>
|
| 152 |
+
<p className="text-sm">Upload some photos and start generating your first immersive 360° view.</p>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
</AnimatePresence>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Gallery Section */}
|
| 161 |
+
<div className="glass-card">
|
| 162 |
+
<div className="flex items-center justify-between mb-6">
|
| 163 |
+
<h3 className="text-lg font-semibold flex items-center gap-2">
|
| 164 |
+
<HistoryIcon className="w-5 h-5 text-primary" />
|
| 165 |
+
Your Gallery
|
| 166 |
+
</h3>
|
| 167 |
+
<span className="text-xs bg-white/10 px-2 py-1 rounded-full text-white/40">
|
| 168 |
+
{history.length} Creations
|
| 169 |
+
</span>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{history.length > 0 ? (
|
| 173 |
+
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
| 174 |
+
{history.map((pano) => (
|
| 175 |
+
<button
|
| 176 |
+
key={pano.id}
|
| 177 |
+
onClick={() => setCurrentPano(pano)}
|
| 178 |
+
className={`group relative aspect-video rounded-xl overflow-hidden border transition-all ${currentPano?.id === pano.id ? 'border-primary ring-2 ring-primary/50' : 'border-white/10 hover:border-white/30'
|
| 179 |
+
}`}
|
| 180 |
+
>
|
| 181 |
+
<img src={pano.url} className="w-full h-full object-cover transition-transform group-hover:scale-110" alt="" />
|
| 182 |
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 183 |
+
<Maximize2 className="w-6 h-6" />
|
| 184 |
+
</div>
|
| 185 |
+
</button>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
) : (
|
| 189 |
+
<div className="py-12 flex flex-col items-center justify-center text-white/10 gap-2">
|
| 190 |
+
<HistoryIcon className="w-8 h-8" />
|
| 191 |
+
<p>No history available</p>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<footer className="mt-auto py-8 text-center text-white/30 text-sm border-t border-white/5 glass">
|
| 199 |
+
<p>© 2024 PanoAI Scene Engine • Powered by Gemini Vision</p>
|
| 200 |
+
</footer>
|
| 201 |
+
</main>
|
| 202 |
+
);
|
| 203 |
+
}
|
src/components/AuthProvider.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { SessionProvider } from "next-auth/react";
|
| 5 |
+
|
| 6 |
+
export default function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 7 |
+
return <SessionProvider>{children}</SessionProvider>;
|
| 8 |
+
}
|
src/components/Navbar.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useSession, signIn, signOut } from "next-auth/react";
|
| 4 |
+
import { LogIn, LogOut, User, Camera, Github, Rocket, History, Download, Share2 } from "lucide-react";
|
| 5 |
+
import Image from "next/image";
|
| 6 |
+
|
| 7 |
+
export default function Navbar() {
|
| 8 |
+
const { data: session } = useSession();
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<nav className="flex items-center justify-between px-6 py-4 glass border-b border-white/10 sticky top-0 z-50">
|
| 12 |
+
<div className="flex items-center gap-2">
|
| 13 |
+
<div className="w-10 h-10 bg-gradient-to-tr from-primary to-accent rounded-xl flex items-center justify-center shadow-lg shadow-primary/20">
|
| 14 |
+
<Camera className="text-white w-6 h-6" />
|
| 15 |
+
</div>
|
| 16 |
+
<span className="text-2xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/60">
|
| 17 |
+
PanoAI
|
| 18 |
+
</span>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div className="hidden md:flex items-center gap-6">
|
| 22 |
+
<button className="text-white/60 hover:text-white transition-colors flex items-center gap-2">
|
| 23 |
+
<Download className="w-4 h-4" />
|
| 24 |
+
<span>Export</span>
|
| 25 |
+
</button>
|
| 26 |
+
<button className="text-white/60 hover:text-white transition-colors flex items-center gap-2">
|
| 27 |
+
<Share2 className="w-4 h-4" />
|
| 28 |
+
<span>Share</span>
|
| 29 |
+
</button>
|
| 30 |
+
<button className="text-white/60 hover:text-white transition-colors flex items-center gap-2">
|
| 31 |
+
<History className="w-4 h-4" />
|
| 32 |
+
<span>History</span>
|
| 33 |
+
</button>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div className="flex items-center gap-4">
|
| 37 |
+
{session ? (
|
| 38 |
+
<div className="flex items-center gap-3">
|
| 39 |
+
<div className="text-right hidden sm:block">
|
| 40 |
+
<p className="text-sm font-medium text-white">{session.user?.name}</p>
|
| 41 |
+
<p className="text-xs text-white/50">{session.user?.email}</p>
|
| 42 |
+
</div>
|
| 43 |
+
{session.user?.image ? (
|
| 44 |
+
<Image
|
| 45 |
+
src={session.user.image}
|
| 46 |
+
alt="Profile"
|
| 47 |
+
width={36}
|
| 48 |
+
height={36}
|
| 49 |
+
className="rounded-full border border-white/20"
|
| 50 |
+
/>
|
| 51 |
+
) : (
|
| 52 |
+
<div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center">
|
| 53 |
+
<User className="w-5 h-5 text-white/60" />
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
<button
|
| 57 |
+
onClick={() => signOut()}
|
| 58 |
+
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-white/60 hover:text-white"
|
| 59 |
+
>
|
| 60 |
+
<LogOut className="w-5 h-5" />
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
) : (
|
| 64 |
+
<button
|
| 65 |
+
onClick={() => signIn("google")}
|
| 66 |
+
className="btn-primary flex items-center gap-2"
|
| 67 |
+
>
|
| 68 |
+
<LogIn className="w-4 h-4" />
|
| 69 |
+
<span>Sign In</span>
|
| 70 |
+
</button>
|
| 71 |
+
)}
|
| 72 |
+
</div>
|
| 73 |
+
</nav>
|
| 74 |
+
);
|
| 75 |
+
}
|
src/components/PanoramaViewer.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useRef, useState } from 'react';
|
| 4 |
+
|
| 5 |
+
interface PanoramaViewerProps {
|
| 6 |
+
imageUrl: string;
|
| 7 |
+
className?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
declare global {
|
| 11 |
+
interface Window {
|
| 12 |
+
pannellum: any;
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const PanoramaViewer: React.FC<PanoramaViewerProps> = ({ imageUrl, className }) => {
|
| 17 |
+
const viewerRef = useRef<HTMLDivElement>(null);
|
| 18 |
+
const pannellumInstance = useRef<any>(null);
|
| 19 |
+
const [error, setError] = useState<string | null>(null);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
// Check if pannellum is loaded
|
| 23 |
+
const initViewer = () => {
|
| 24 |
+
if (!window.pannellum) {
|
| 25 |
+
console.warn("Pannellum not loaded yet, retrying...");
|
| 26 |
+
setTimeout(initViewer, 500);
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if (viewerRef.current && imageUrl) {
|
| 31 |
+
// Destroy existing instance if it exists
|
| 32 |
+
if (pannellumInstance.current) {
|
| 33 |
+
try {
|
| 34 |
+
pannellumInstance.current.destroy();
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.warn("Error destroying pannellum instance:", e);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
// Initialize Pannellum
|
| 42 |
+
pannellumInstance.current = window.pannellum.viewer(viewerRef.current, {
|
| 43 |
+
type: 'equirectangular',
|
| 44 |
+
panorama: imageUrl,
|
| 45 |
+
autoLoad: true,
|
| 46 |
+
showControls: true,
|
| 47 |
+
compass: false, // Set to false by default for cleaner look
|
| 48 |
+
mouseZoom: true,
|
| 49 |
+
hfov: 100,
|
| 50 |
+
vaov: 180,
|
| 51 |
+
haov: 360,
|
| 52 |
+
crossOrigin: "anonymous"
|
| 53 |
+
});
|
| 54 |
+
setError(null);
|
| 55 |
+
} catch (err: any) {
|
| 56 |
+
console.error("Pannellum initialization error:", err);
|
| 57 |
+
setError(err.message);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
initViewer();
|
| 63 |
+
|
| 64 |
+
return () => {
|
| 65 |
+
if (pannellumInstance.current) {
|
| 66 |
+
try {
|
| 67 |
+
pannellumInstance.current.destroy();
|
| 68 |
+
} catch (e) { }
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
}, [imageUrl]);
|
| 72 |
+
|
| 73 |
+
if (error) {
|
| 74 |
+
return (
|
| 75 |
+
<div className="w-full h-full flex items-center justify-center bg-slate-900 text-red-400 p-4 text-center">
|
| 76 |
+
<p>Error loading viewer: {error}<br />Image might be too large or inaccessible.</p>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<div
|
| 83 |
+
ref={viewerRef}
|
| 84 |
+
className={`w-full h-full rounded-xl overflow-hidden shadow-2xl bg-black border border-slate-700 ${className || ''}`}
|
| 85 |
+
/>
|
| 86 |
+
);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
export default PanoramaViewer;
|
src/components/UploadSection.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Upload, X, Image as ImageIcon, Sparkles } from "lucide-react";
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 6 |
+
|
| 7 |
+
interface UploadSectionProps {
|
| 8 |
+
onGenerate: (prompt: string, images: File[]) => void;
|
| 9 |
+
isGenerating: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function UploadSection({ onGenerate, isGenerating }: UploadSectionProps) {
|
| 13 |
+
const [prompt, setPrompt] = useState("");
|
| 14 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 15 |
+
|
| 16 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 17 |
+
if (e.target.files) {
|
| 18 |
+
setFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
|
| 19 |
+
}
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const removeFile = (index: number) => {
|
| 23 |
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="flex flex-col gap-6">
|
| 28 |
+
<div className="glass-card">
|
| 29 |
+
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
| 30 |
+
<Upload className="w-5 h-5 text-primary" />
|
| 31 |
+
Upload Reference Photos
|
| 32 |
+
</h3>
|
| 33 |
+
|
| 34 |
+
<div
|
| 35 |
+
className="border-2 border-dashed border-white/10 rounded-xl p-8 flex flex-col items-center justify-center gap-4 hover:border-primary/50 hover:bg-white/5 transition-all cursor-pointer relative"
|
| 36 |
+
onClick={() => document.getElementById("fileInput")?.click()}
|
| 37 |
+
>
|
| 38 |
+
<input
|
| 39 |
+
id="fileInput"
|
| 40 |
+
type="file"
|
| 41 |
+
multiple
|
| 42 |
+
accept="image/*"
|
| 43 |
+
className="hidden"
|
| 44 |
+
onChange={handleFileChange}
|
| 45 |
+
/>
|
| 46 |
+
<div className="w-12 h-12 bg-white/5 rounded-full flex items-center justify-center">
|
| 47 |
+
<Upload className="w-6 h-6 text-white/40" />
|
| 48 |
+
</div>
|
| 49 |
+
<div className="text-center">
|
| 50 |
+
<p className="font-medium">Click to browse or drag and drop</p>
|
| 51 |
+
<p className="text-sm text-white/30">(Supports multiple JPG/PNG photos)</p>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="grid grid-cols-4 gap-2 mt-4">
|
| 56 |
+
<AnimatePresence>
|
| 57 |
+
{files.map((file, i) => (
|
| 58 |
+
<motion.div
|
| 59 |
+
key={i}
|
| 60 |
+
initial={{ scale: 0, opacity: 0 }}
|
| 61 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 62 |
+
exit={{ scale: 0, opacity: 0 }}
|
| 63 |
+
className="relative aspect-square rounded-lg overflow-hidden group border border-white/10"
|
| 64 |
+
>
|
| 65 |
+
<img
|
| 66 |
+
src={URL.createObjectURL(file)}
|
| 67 |
+
alt="Preview"
|
| 68 |
+
className="w-full h-full object-cover"
|
| 69 |
+
/>
|
| 70 |
+
<button
|
| 71 |
+
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
| 72 |
+
className="absolute top-1 right-1 p-1 bg-black/60 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
| 73 |
+
>
|
| 74 |
+
<X className="w-3 h-3" />
|
| 75 |
+
</button>
|
| 76 |
+
</motion.div>
|
| 77 |
+
))}
|
| 78 |
+
</AnimatePresence>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<div className="glass-card">
|
| 83 |
+
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
| 84 |
+
<Sparkles className="w-5 h-5 text-primary" />
|
| 85 |
+
Generation Details
|
| 86 |
+
</h3>
|
| 87 |
+
|
| 88 |
+
<label className="text-xs uppercase tracking-wider text-white/40 mb-2 block">
|
| 89 |
+
Style / Description (Optional)
|
| 90 |
+
</label>
|
| 91 |
+
<textarea
|
| 92 |
+
value={prompt}
|
| 93 |
+
onChange={(e) => setPrompt(e.target.value)}
|
| 94 |
+
placeholder="e.g., A modern living room with warm sunset lighting and panoramic window views..."
|
| 95 |
+
className="w-full h-32 input-field resize-none mb-4"
|
| 96 |
+
/>
|
| 97 |
+
|
| 98 |
+
<button
|
| 99 |
+
onClick={() => onGenerate(prompt, files)}
|
| 100 |
+
disabled={isGenerating || (!prompt && files.length === 0)}
|
| 101 |
+
className="w-full btn-primary py-4 flex items-center justify-center gap-2"
|
| 102 |
+
>
|
| 103 |
+
{isGenerating ? (
|
| 104 |
+
<div className="flex items-center gap-2">
|
| 105 |
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 106 |
+
<span>Generating...</span>
|
| 107 |
+
</div>
|
| 108 |
+
) : (
|
| 109 |
+
<>
|
| 110 |
+
<Sparkles className="w-5 h-5" />
|
| 111 |
+
<span>Create 360° Panorama</span>
|
| 112 |
+
</>
|
| 113 |
+
)}
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
}
|
src/lib/auth.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextAuthOptions } from "next-auth";
|
| 2 |
+
import GoogleProvider from "next-auth/providers/google";
|
| 3 |
+
|
| 4 |
+
export const authOptions: NextAuthOptions = {
|
| 5 |
+
providers: [
|
| 6 |
+
GoogleProvider({
|
| 7 |
+
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
| 8 |
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
| 9 |
+
}),
|
| 10 |
+
],
|
| 11 |
+
secret: process.env.NEXTAUTH_SECRET,
|
| 12 |
+
pages: {
|
| 13 |
+
signIn: "/",
|
| 14 |
+
},
|
| 15 |
+
callbacks: {
|
| 16 |
+
async session({ session, token }) {
|
| 17 |
+
if (session.user) {
|
| 18 |
+
(session.user as any).id = token.sub;
|
| 19 |
+
}
|
| 20 |
+
return session;
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
};
|
src/lib/gemini.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
| 2 |
+
|
| 3 |
+
function getApiKeys() {
|
| 4 |
+
return [
|
| 5 |
+
process.env.GEMINI_API_KEY,
|
| 6 |
+
process.env.GEMINI_API_KEY_SECONDARY,
|
| 7 |
+
].filter(Boolean) as string[];
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export async function getGeminiModel(index = 0, modelName = "gemini-2.0-flash") {
|
| 11 |
+
const keys = getApiKeys();
|
| 12 |
+
if (index >= keys.length) {
|
| 13 |
+
throw new Error("All Gemini API keys have been exhausted.");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const genAI = new GoogleGenerativeAI(keys[index]);
|
| 17 |
+
return genAI.getGenerativeModel({ model: modelName });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export async function generateWithFallback(
|
| 21 |
+
fn: (model: any) => Promise<any>,
|
| 22 |
+
index = 0,
|
| 23 |
+
modelName = "gemini-2.0-flash"
|
| 24 |
+
): Promise<any> {
|
| 25 |
+
const keys = getApiKeys();
|
| 26 |
+
try {
|
| 27 |
+
const model = await getGeminiModel(index, modelName);
|
| 28 |
+
return await fn(model);
|
| 29 |
+
} catch (error: any) {
|
| 30 |
+
console.warn(`API key ${index} for model ${modelName} failed, trying next...`, error.message);
|
| 31 |
+
if (index + 1 < keys.length) {
|
| 32 |
+
return await generateWithFallback(fn, index + 1, modelName);
|
| 33 |
+
}
|
| 34 |
+
throw error;
|
| 35 |
+
}
|
| 36 |
+
}
|
src/tests/Navbar.test.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 2 |
+
import Navbar from '@/components/Navbar'
|
| 3 |
+
import { useSession, signIn, signOut } from 'next-auth/react'
|
| 4 |
+
|
| 5 |
+
// Mocks are already setup in global or hoisted.
|
| 6 |
+
// But we need to control useSession specifically here.
|
| 7 |
+
// In jest.setup.js? No, I mocked it globally?
|
| 8 |
+
// No, I mocked it in Navbar.test.tsx previously.
|
| 9 |
+
// But jest resets mocks.
|
| 10 |
+
// So I mock it here again.
|
| 11 |
+
|
| 12 |
+
jest.mock('next-auth/react', () => ({
|
| 13 |
+
useSession: jest.fn(() => ({ data: null, status: 'unauthenticated' })),
|
| 14 |
+
signIn: jest.fn(),
|
| 15 |
+
signOut: jest.fn(),
|
| 16 |
+
}))
|
| 17 |
+
|
| 18 |
+
describe('Navbar', () => {
|
| 19 |
+
|
| 20 |
+
beforeEach(() => {
|
| 21 |
+
jest.clearAllMocks()
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('renders logo and sign in button when logged out', () => {
|
| 25 |
+
(useSession as jest.Mock).mockReturnValue({ data: null, status: 'unauthenticated' })
|
| 26 |
+
|
| 27 |
+
render(<Navbar />)
|
| 28 |
+
|
| 29 |
+
expect(screen.getByText('PanoAI')).toBeInTheDocument()
|
| 30 |
+
const signInBtn = screen.getByText('Sign In')
|
| 31 |
+
expect(signInBtn).toBeInTheDocument()
|
| 32 |
+
|
| 33 |
+
fireEvent.click(signInBtn)
|
| 34 |
+
expect(signIn).toHaveBeenCalledWith('google')
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
it('renders user info and sign out button when logged in', () => {
|
| 38 |
+
const mockUser = { name: 'Test User', email: 'test@example.com', image: 'test.jpg' };
|
| 39 |
+
(useSession as jest.Mock).mockReturnValue({
|
| 40 |
+
data: { user: mockUser },
|
| 41 |
+
status: 'authenticated'
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
render(<Navbar />)
|
| 45 |
+
|
| 46 |
+
expect(screen.getByText('Test User')).toBeInTheDocument()
|
| 47 |
+
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
| 48 |
+
|
| 49 |
+
// Sign out button is an icon button. LogOut icon mocked as icon-LogOut
|
| 50 |
+
// Find by icon testid
|
| 51 |
+
const signOutBtn = screen.getByTestId('icon-LogOut').closest('button')
|
| 52 |
+
|
| 53 |
+
fireEvent.click(signOutBtn!)
|
| 54 |
+
expect(signOut).toHaveBeenCalled()
|
| 55 |
+
})
|
| 56 |
+
})
|
src/tests/PanoramaViewer.test.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from '@testing-library/react'
|
| 2 |
+
import PanoramaViewer from '@/components/PanoramaViewer'
|
| 3 |
+
|
| 4 |
+
describe('PanoramaViewer', () => {
|
| 5 |
+
it('renders the container div', () => {
|
| 6 |
+
render(<PanoramaViewer imageUrl="test.jpg" />)
|
| 7 |
+
// Since it's Pannellum, it just renders a div with a ref
|
| 8 |
+
const container = document.querySelector('div[id^="panorama-"]');
|
| 9 |
+
expect(container).toBeDefined();
|
| 10 |
+
})
|
| 11 |
+
})
|
src/tests/UploadSection.test.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 2 |
+
import UploadSection from '@/components/UploadSection'
|
| 3 |
+
|
| 4 |
+
describe('UploadSection', () => {
|
| 5 |
+
const mockOnGenerate = jest.fn()
|
| 6 |
+
|
| 7 |
+
beforeEach(() => {
|
| 8 |
+
jest.clearAllMocks()
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
it('renders upload area and input', () => {
|
| 12 |
+
render(<UploadSection onGenerate={mockOnGenerate} isGenerating={false} />)
|
| 13 |
+
expect(screen.getByText('Upload Reference Photos')).toBeInTheDocument()
|
| 14 |
+
expect(screen.getByPlaceholderText(/A modern living room/)).toBeInTheDocument()
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
it('calls onGenerate when button clicked', () => {
|
| 18 |
+
render(<UploadSection onGenerate={mockOnGenerate} isGenerating={false} />)
|
| 19 |
+
const textarea = screen.getByPlaceholderText(/A modern living room/)
|
| 20 |
+
fireEvent.change(textarea, { target: { value: 'Beautiful sunset' } })
|
| 21 |
+
|
| 22 |
+
const button = screen.getByText(/Create 360/)
|
| 23 |
+
fireEvent.click(button)
|
| 24 |
+
|
| 25 |
+
expect(mockOnGenerate).toHaveBeenCalledWith('Beautiful sunset', [])
|
| 26 |
+
})
|
| 27 |
+
})
|
src/tests/api.test.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { POST } from '@/app/api/generate/route'
|
| 2 |
+
import { exec } from 'child_process'
|
| 3 |
+
import fs from 'fs'
|
| 4 |
+
|
| 5 |
+
// Mock next/server
|
| 6 |
+
jest.mock('next/server', () => ({
|
| 7 |
+
NextResponse: {
|
| 8 |
+
json: (body: any, init?: any) => ({
|
| 9 |
+
json: async () => body,
|
| 10 |
+
status: init?.status || 200,
|
| 11 |
+
}),
|
| 12 |
+
},
|
| 13 |
+
}))
|
| 14 |
+
|
| 15 |
+
// Mock child_process
|
| 16 |
+
jest.mock('child_process', () => ({
|
| 17 |
+
exec: jest.fn(),
|
| 18 |
+
}))
|
| 19 |
+
|
| 20 |
+
// Mock fs and os
|
| 21 |
+
jest.mock('fs', () => ({
|
| 22 |
+
existsSync: jest.fn().mockReturnValue(true),
|
| 23 |
+
mkdirSync: jest.fn(),
|
| 24 |
+
writeFileSync: jest.fn(),
|
| 25 |
+
unlinkSync: jest.fn(),
|
| 26 |
+
rmdirSync: jest.fn(),
|
| 27 |
+
}))
|
| 28 |
+
|
| 29 |
+
jest.mock('os', () => ({
|
| 30 |
+
tmpdir: () => '/tmp',
|
| 31 |
+
}))
|
| 32 |
+
|
| 33 |
+
const createMockRequest = (body: any) => ({
|
| 34 |
+
formData: async () => {
|
| 35 |
+
const data = new Map();
|
| 36 |
+
Object.keys(body).forEach(key => {
|
| 37 |
+
if (Array.isArray(body[key])) {
|
| 38 |
+
data.set(key, body[key]);
|
| 39 |
+
} else {
|
| 40 |
+
data.set(key, body[key]);
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
return {
|
| 44 |
+
get: (key: string) => body[key],
|
| 45 |
+
getAll: (key: string) => Array.isArray(body[key]) ? body[key] : [body[key]],
|
| 46 |
+
};
|
| 47 |
+
},
|
| 48 |
+
}) as any
|
| 49 |
+
|
| 50 |
+
describe('Generate API (Hybrid Pipeline)', () => {
|
| 51 |
+
beforeEach(() => {
|
| 52 |
+
jest.clearAllMocks()
|
| 53 |
+
process.env.STABILITY_API_KEY = 'test-key'
|
| 54 |
+
jest.spyOn(console, 'error').mockImplementation(() => { })
|
| 55 |
+
jest.spyOn(console, 'log').mockImplementation(() => { })
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
it('returns success for valid request via hybrid pipeline', async () => {
|
| 59 |
+
const mockImage = new File(['test'], 'test.png', { type: 'image/png' })
|
| 60 |
+
const req = createMockRequest({
|
| 61 |
+
prompt: 'test prompt',
|
| 62 |
+
images: [mockImage]
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
const mockStdout = JSON.stringify({
|
| 66 |
+
success: true,
|
| 67 |
+
image: 'data:image/png;base64,mockdata'
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
(exec as unknown as jest.Mock).mockImplementation((cmd, options, callback) => {
|
| 71 |
+
const cb = typeof options === 'function' ? options : callback;
|
| 72 |
+
process.nextTick(() => cb(null, mockStdout, ''));
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
const response = await POST(req)
|
| 76 |
+
const json = await response.json()
|
| 77 |
+
|
| 78 |
+
expect(response.status).toBe(200)
|
| 79 |
+
expect(json.success).toBe(true)
|
| 80 |
+
// Check if images were "saved" (our mock should have triggered fs.writeFileSync)
|
| 81 |
+
expect(fs.writeFileSync).toHaveBeenCalled()
|
| 82 |
+
expect(json.method).toBe('cv_ai_hybrid')
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
it('returns 500 on pipeline failure', async () => {
|
| 86 |
+
const mockImage = new File(['test'], 'test.png', { type: 'image/png' })
|
| 87 |
+
const req = createMockRequest({
|
| 88 |
+
prompt: 'fail',
|
| 89 |
+
images: [mockImage]
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
const mockError = JSON.stringify({
|
| 93 |
+
success: false,
|
| 94 |
+
error: 'AI Error'
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
(exec as unknown as jest.Mock).mockImplementation((cmd, options, callback) => {
|
| 98 |
+
const cb = typeof options === 'function' ? options : callback;
|
| 99 |
+
cb(null, mockError, '')
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
const response = await POST(req)
|
| 103 |
+
const json = await response.json()
|
| 104 |
+
|
| 105 |
+
expect(response.status).toBe(500)
|
| 106 |
+
expect(json.message).toBe('AI Error')
|
| 107 |
+
})
|
| 108 |
+
})
|
src/tests/gemini.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateWithFallback } from "@/lib/gemini";
|
| 2 |
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
| 3 |
+
|
| 4 |
+
jest.mock("@google/generative-ai");
|
| 5 |
+
|
| 6 |
+
describe("Gemini API Fallback", () => {
|
| 7 |
+
beforeEach(() => {
|
| 8 |
+
jest.clearAllMocks();
|
| 9 |
+
process.env.GEMINI_API_KEY = "key1";
|
| 10 |
+
process.env.GEMINI_API_KEY_SECONDARY = "key2";
|
| 11 |
+
|
| 12 |
+
// Suppress console warnings in tests
|
| 13 |
+
jest.spyOn(console, 'warn').mockImplementation(() => { });
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
afterEach(() => {
|
| 17 |
+
jest.restoreAllMocks();
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("should use the first key if successful", async () => {
|
| 21 |
+
const mockGenerateContent = jest.fn().mockResolvedValue({
|
| 22 |
+
response: { text: () => "Success" }
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
(GoogleGenerativeAI as jest.Mock).mockImplementation(() => ({
|
| 26 |
+
getGenerativeModel: () => ({
|
| 27 |
+
generateContent: mockGenerateContent,
|
| 28 |
+
}),
|
| 29 |
+
}));
|
| 30 |
+
|
| 31 |
+
const result = await generateWithFallback(async (model) => {
|
| 32 |
+
return await model.generateContent("test");
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
expect(result.response.text()).toBe("Success");
|
| 36 |
+
expect(GoogleGenerativeAI).toHaveBeenCalledTimes(1);
|
| 37 |
+
expect(GoogleGenerativeAI).toHaveBeenCalledWith("key1");
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it("should fallback to second key if first fails", async () => {
|
| 41 |
+
const mockGenerateContent = jest.fn()
|
| 42 |
+
.mockRejectedValueOnce(new Error("Quota exceeded"))
|
| 43 |
+
.mockResolvedValueOnce({ response: { text: () => "Success 2" } });
|
| 44 |
+
|
| 45 |
+
(GoogleGenerativeAI as jest.Mock).mockImplementation(() => ({
|
| 46 |
+
getGenerativeModel: () => ({
|
| 47 |
+
generateContent: mockGenerateContent,
|
| 48 |
+
}),
|
| 49 |
+
}));
|
| 50 |
+
|
| 51 |
+
const result = await generateWithFallback(async (model) => {
|
| 52 |
+
return await model.generateContent("test");
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
expect(result.response.text()).toBe("Success 2");
|
| 56 |
+
expect(GoogleGenerativeAI).toHaveBeenCalledTimes(2);
|
| 57 |
+
expect(GoogleGenerativeAI).toHaveBeenNthCalledWith(1, "key1");
|
| 58 |
+
expect(GoogleGenerativeAI).toHaveBeenNthCalledWith(2, "key2");
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("should throw error when all keys exhausted", async () => {
|
| 62 |
+
const mockGenerateContent = jest.fn()
|
| 63 |
+
.mockRejectedValue(new Error("Quota exceeded"));
|
| 64 |
+
|
| 65 |
+
(GoogleGenerativeAI as jest.Mock).mockImplementation(() => ({
|
| 66 |
+
getGenerativeModel: () => ({
|
| 67 |
+
generateContent: mockGenerateContent,
|
| 68 |
+
}),
|
| 69 |
+
}));
|
| 70 |
+
|
| 71 |
+
await expect(generateWithFallback(async (model) => {
|
| 72 |
+
return await model.generateContent("test");
|
| 73 |
+
})).rejects.toThrow("Quota exceeded");
|
| 74 |
+
|
| 75 |
+
expect(GoogleGenerativeAI).toHaveBeenCalledTimes(2);
|
| 76 |
+
});
|
| 77 |
+
});
|
src/tests/page.test.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
| 2 |
+
import Home from '@/app/page'
|
| 3 |
+
|
| 4 |
+
// Mock dependencies
|
| 5 |
+
jest.mock('next-auth/react', () => ({
|
| 6 |
+
useSession: jest.fn(() => ({ data: null })),
|
| 7 |
+
signIn: jest.fn(),
|
| 8 |
+
signOut: jest.fn(),
|
| 9 |
+
}))
|
| 10 |
+
|
| 11 |
+
jest.mock('next/dynamic', () => () => {
|
| 12 |
+
const DynamicComponent = () => <div>PanoramaViewer Mock</div>
|
| 13 |
+
DynamicComponent.displayName = 'PanoramaViewer'
|
| 14 |
+
return DynamicComponent
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
// Mock child components
|
| 18 |
+
jest.mock('@/components/Navbar', () => () => <div data-testid="navbar">Navbar</div>)
|
| 19 |
+
jest.mock('@/components/UploadSection', () => ({ onGenerate, isGenerating }: any) => (
|
| 20 |
+
<div data-testid="upload-section">
|
| 21 |
+
<button onClick={() => onGenerate('test prompt', [])} disabled={isGenerating}>Generate</button>
|
| 22 |
+
</div>
|
| 23 |
+
))
|
| 24 |
+
|
| 25 |
+
describe('Home Page', () => {
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
localStorage.clear()
|
| 28 |
+
jest.clearAllMocks()
|
| 29 |
+
jest.useFakeTimers()
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
afterEach(() => {
|
| 33 |
+
jest.useRealTimers()
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
it('renders initial state correctly', () => {
|
| 37 |
+
render(<Home />)
|
| 38 |
+
expect(screen.getByTestId('navbar')).toBeInTheDocument()
|
| 39 |
+
expect(screen.getByTestId('upload-section')).toBeInTheDocument()
|
| 40 |
+
expect(screen.getByText('No panoramas yet')).toBeInTheDocument()
|
| 41 |
+
expect(screen.getByText('Your Gallery')).toBeInTheDocument()
|
| 42 |
+
expect(screen.getByText('0 Creations')).toBeInTheDocument()
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
it('generates panorama successfully', async () => {
|
| 46 |
+
// Mock fetch success
|
| 47 |
+
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
| 48 |
+
ok: true,
|
| 49 |
+
json: async () => ({
|
| 50 |
+
success: true,
|
| 51 |
+
url: 'data:image/png;base64,mock',
|
| 52 |
+
method: 'cv_ai_hybrid'
|
| 53 |
+
})
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
render(<Home />)
|
| 57 |
+
const generateBtn = screen.getByText('Generate')
|
| 58 |
+
|
| 59 |
+
await act(async () => {
|
| 60 |
+
fireEvent.click(generateBtn)
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
await waitFor(() => {
|
| 64 |
+
expect(screen.getByText('PanoramaViewer Mock')).toBeInTheDocument()
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
expect(screen.getByText('1 Creations')).toBeInTheDocument()
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
it('loads history from localStorage', async () => {
|
| 71 |
+
const mockHistory = [{
|
| 72 |
+
id: 'test-id',
|
| 73 |
+
url: '/mock-local-url.jpg',
|
| 74 |
+
prompt: 'test prompt',
|
| 75 |
+
timestamp: new Date().toISOString()
|
| 76 |
+
}]
|
| 77 |
+
|
| 78 |
+
Storage.prototype.getItem = jest.fn(() => JSON.stringify(mockHistory));
|
| 79 |
+
|
| 80 |
+
render(<Home />)
|
| 81 |
+
|
| 82 |
+
await waitFor(() => {
|
| 83 |
+
expect(screen.getByText('1 Creations')).toBeInTheDocument()
|
| 84 |
+
})
|
| 85 |
+
expect(screen.getByText('PanoramaViewer Mock')).toBeInTheDocument()
|
| 86 |
+
})
|
| 87 |
+
})
|
src/types/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface Panorama {
|
| 2 |
+
id: string;
|
| 3 |
+
url: string;
|
| 4 |
+
prompt: string;
|
| 5 |
+
timestamp: Date;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface UserSession {
|
| 9 |
+
user: {
|
| 10 |
+
name?: string | null;
|
| 11 |
+
email?: string | null;
|
| 12 |
+
image?: string | null;
|
| 13 |
+
id?: string;
|
| 14 |
+
};
|
| 15 |
+
}
|
src/types/three.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="@react-three/fiber" />
|
| 2 |
+
|
| 3 |
+
import * as THREE from 'three'
|
| 4 |
+
import { ThreeElements } from '@react-three/fiber'
|
| 5 |
+
|
| 6 |
+
declare global {
|
| 7 |
+
namespace JSX {
|
| 8 |
+
interface IntrinsicElements extends ThreeElements { }
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
declare module 'react' {
|
| 13 |
+
namespace JSX {
|
| 14 |
+
interface IntrinsicElements extends ThreeElements { }
|
| 15 |
+
}
|
| 16 |
+
}
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: [
|
| 5 |
+
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
| 6 |
+
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 7 |
+
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 8 |
+
],
|
| 9 |
+
theme: {
|
| 10 |
+
extend: {
|
| 11 |
+
colors: {
|
| 12 |
+
background: "var(--background)",
|
| 13 |
+
foreground: "var(--foreground)",
|
| 14 |
+
primary: {
|
| 15 |
+
DEFAULT: "#6366f1",
|
| 16 |
+
dark: "#4f46e5",
|
| 17 |
+
},
|
| 18 |
+
accent: {
|
| 19 |
+
DEFAULT: "#f43f5e",
|
| 20 |
+
dark: "#e11d48",
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
backgroundImage: {
|
| 24 |
+
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
| 25 |
+
"gradient-conic":
|
| 26 |
+
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
| 27 |
+
},
|
| 28 |
+
animation: {
|
| 29 |
+
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
plugins: [],
|
| 34 |
+
};
|
| 35 |
+
export default config;
|
tasks/panno-ai-api.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Panno-AI-API 项目详细设计与实施指南
|
| 2 |
+
|
| 3 |
+
## 1. 项目背景与需求分析
|
| 4 |
+
### 1.1 核心痛点
|
| 5 |
+
- **环境限制**:Vercel 等 Serverless 平台不支持 OpenCV (C++) 底层编译库,无法进行物理图像拼接。
|
| 6 |
+
- **性能瓶颈**:全景图拼接属于 CPU/内存密集型任务,Serverless 函数容易超时且资源受限。
|
| 7 |
+
- **模型依赖**:未来的拼接算法可能需要深度学习模型(如深度估算),需要一个真正的服务器环境。
|
| 8 |
+
|
| 9 |
+
### 1.2 项目目标
|
| 10 |
+
构建一个专门运行在 Hugging Face Spaces 上的 Python 运算引擎,为前端提供高精度的全景图生成服务。
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 2. 核心功能规范
|
| 15 |
+
### 2.1 智能拼接管道 (CV Pipeline)
|
| 16 |
+
- **输入检测**:自动识别上传图片的重叠区域。
|
| 17 |
+
- **Stitching 算法**:利用 OpenCV 的 `Stitcher` 类(基于 SIFT/SURF 特征点)进行特征匹配。
|
| 18 |
+
- **几何校正**:将拼接后的图像投影为 2:1 的等距柱状全景视图。
|
| 19 |
+
|
| 20 |
+
### 2.2 AI 智能缝隙补全 (AI Inpainting)
|
| 21 |
+
- **掩码生成**:自动识别拼接后留下的黑边(通常是天花板和地面)。
|
| 22 |
+
- **生成式扩展**:调用 Stability AI SDK,利用图像上下文和用户提示词(Prompt)进行“无缝补全”。
|
| 23 |
+
|
| 24 |
+
### 2.3 视觉特征分析
|
| 25 |
+
- 利用 Gemini 2.0 Flash 视觉能力分析参考图,确保补全的纹理(如木地板、石膏顶)与原图 100% 匹配。
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 3. 接口设计 (API Specification)
|
| 30 |
+
|
| 31 |
+
### 接口 URL: `POST /v1/generate`
|
| 32 |
+
|
| 33 |
+
#### 3.1 请求头 (Headers)
|
| 34 |
+
| Key | Value | 说明 |
|
| 35 |
+
| :--- | :--- | :--- |
|
| 36 |
+
| `Content-Type` | `application/json` | |
|
| 37 |
+
| `X-API-Key` | `PROD_SECRET_PASSWORD` | 用于 Vercel 与 HF 之间的身份校验 |
|
| 38 |
+
|
| 39 |
+
#### 3.2 请求体 (Request Body)
|
| 40 |
+
```json
|
| 41 |
+
{
|
| 42 |
+
"prompt": "现代简约风格客厅,阳光充足",
|
| 43 |
+
"style": "photographic",
|
| 44 |
+
"images": [
|
| 45 |
+
"data:image/png;base64,iVBORw0K...", // 原始参考图1
|
| 46 |
+
"data:image/png;base64,iVBORw0K..." // 原始参考图2
|
| 47 |
+
]
|
| 48 |
+
}
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
#### 3.3 返回体 (Response Body)
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"success": true,
|
| 55 |
+
"image": "data:image/webp;base64,UklGRk...", // 最终全景图
|
| 56 |
+
"method": "cv_ai_hybrid", // 处理方法
|
| 57 |
+
"details": {
|
| 58 |
+
"num_stitched": 5,
|
| 59 |
+
"has_inpainting": true
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 4. 核心实现代码预览
|
| 67 |
+
|
| 68 |
+
### 4.1 FastAPI 主入口 (`main.py`)
|
| 69 |
+
```python
|
| 70 |
+
from fastapi import FastAPI, HTTPException, Header
|
| 71 |
+
from pydantic import BaseModel
|
| 72 |
+
import base64
|
| 73 |
+
import os
|
| 74 |
+
from service.processor import process_panorama # 封装拼接逻辑
|
| 75 |
+
|
| 76 |
+
app = FastAPI()
|
| 77 |
+
|
| 78 |
+
class PannoRequest(BaseModel):
|
| 79 |
+
prompt: str
|
| 80 |
+
images: list[str]
|
| 81 |
+
style: str = "photographic"
|
| 82 |
+
|
| 83 |
+
@app.post("/v1/generate")
|
| 84 |
+
async def generate(request: PannoRequest, x_api_key: str = Header(None)):
|
| 85 |
+
# 1. 简易安全校验
|
| 86 |
+
if x_api_key != os.getenv("AUTH_TOKEN"):
|
| 87 |
+
raise HTTPException(status_code=403, detail="Unauthorized")
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# 2. 调用处理函数
|
| 91 |
+
result_base64 = process_panorama(request.images, request.prompt)
|
| 92 |
+
return {
|
| 93 |
+
"success": True,
|
| 94 |
+
"image": f"data:image/webp;base64,{result_base64}",
|
| 95 |
+
"method": "cv_ai_hybrid"
|
| 96 |
+
}
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return {"success": False, "error": str(e)}
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 5. Hugging Face 部署关键配置
|
| 104 |
+
|
| 105 |
+
### 5.1 `packages.txt` (系统依赖)
|
| 106 |
+
HF 的 Docker 基础镜像通过此文件安装系统库。
|
| 107 |
+
```text
|
| 108 |
+
libgl1-mesa-glx
|
| 109 |
+
libglib2.0-ext
|
| 110 |
+
git
|
| 111 |
+
python3-opencv
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 5.2 `requirements.txt` (Python 依赖)
|
| 115 |
+
```text
|
| 116 |
+
fastapi
|
| 117 |
+
uvicorn
|
| 118 |
+
opencv-python-headless
|
| 119 |
+
numpy
|
| 120 |
+
requests
|
| 121 |
+
google-generativeai
|
| 122 |
+
python-multipart
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## 6. 与前端 Next.js 的配合流程
|
| 128 |
+
|
| 129 |
+
### 6.1 通信链路
|
| 130 |
+
1. **用户操作**:在前端页面上传 5 张照片,点击“生成”。
|
| 131 |
+
2. **Next.js 接收**:Next.js 后端路由 `api/generate` 接收到 Base64 图片。
|
| 132 |
+
3. **分发请求**:Next.js 通过 `fetch` 将数据转发给 Hugging Face 上的 Panno-AI-API。
|
| 133 |
+
4. **后端运算**:HF 上的 Python 引擎进行拼接和 AI 扩图,返回最终大图。
|
| 134 |
+
5. **前端展示**:Next.js 将结果直接传回浏览器预览,并保存至用户的 LocalStorage 或数据库。
|
| 135 |
+
|
| 136 |
+
### 6.2 环境变量同步
|
| 137 |
+
- **Vercel 后台配置**:
|
| 138 |
+
- `REMOTE_WORKER_URL`: 指向 HF Space 的地址。
|
| 139 |
+
- `REMOTE_WORKER_KEY`: 与 HF 上的 `AUTH_TOKEN` 保持一致。
|
| 140 |
+
|
| 141 |
+
- **Hugging Face 后台配置**:
|
| 142 |
+
- `AUTH_TOKEN`: 自定义强密码。
|
| 143 |
+
- `STABILITY_API_KEY`: AI 补全密钥。
|
| 144 |
+
- `GEMINI_API_KEY`: 视觉分析密钥。
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## 7. 下一步行动 (Action Plan)
|
| 149 |
+
1. **初始化仓库**:在 `Panno-AI-API` 目录下创建上述文件。
|
| 150 |
+
2. **本地测试**:在本地使用 `uvicorn main:app --reload` 运行 API。
|
| 151 |
+
3. **HF 部署**:推送代码到 GitHub 关联 HF Space 自动构建。
|
| 152 |
+
4. **联调**:在本地 Next.js 中填入 HF 的测试参数进行端到端测试。
|
test_output.txt
ADDED
|
Binary file (4.26 kB). View file
|
|
|
test_output_v2.txt
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node.exe : FAIL src/tests/Navbar.test.tsx
|
| 2 |
+
At line:1 char:1
|
| 3 |
+
+ & "C:\nvm\nodejs/node.exe" "C:\nvm\nodejs/node_mo
|
| 4 |
+
dules/npm/bin/npx-cl ...
|
| 5 |
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
| 6 |
+
~~~~~~~~~~~~~~~~~~~~
|
| 7 |
+
+ CategoryInfo : NotSpecified: (FAIL
|
| 8 |
+
src/tests/Navbar.test.tsx:String) [], RemoteEx
|
| 9 |
+
ception
|
| 10 |
+
+ FullyQualifiedErrorId : NativeCommandError
|
| 11 |
+
|
| 12 |
+
??? Test suite failed to run
|
| 13 |
+
|
| 14 |
+
Cannot find module '@testing-library/dom' from
|
| 15 |
+
'node_modules/@testing-library/react/dist/pure.js'
|
| 16 |
+
|
| 17 |
+
Require stack:
|
| 18 |
+
node_modules/@testing-library/react/dist/pure
|
| 19 |
+
.js
|
| 20 |
+
node_modules/@testing-library/react/dist/inde
|
| 21 |
+
x.js
|
| 22 |
+
src/tests/Navbar.test.tsx
|
| 23 |
+
|
| 24 |
+
[0m [90m 12 |[39m it([32m'renders logo
|
| 25 |
+
and sign in button'[39m[33m,[39m () [33m=>[39m
|
| 26 |
+
{
|
| 27 |
+
[90m 13 |[39m render(
|
| 28 |
+
[31m[1m>[22m[39m[90m 14 |[39m
|
| 29 |
+
[33m<[39m[33mSessionProvider[39m session[33m=
|
| 30 |
+
[39m{[36mnull[39m}[33m>[39m
|
| 31 |
+
[90m |[39m [31m[1m^[22m
|
| 32 |
+
[39m
|
| 33 |
+
[90m 15 |[39m [33m<[39m[3
|
| 34 |
+
3mNavbar[39m [33m/[39m[33m>[39m
|
| 35 |
+
[90m 16 |[39m [33m<[39m[33m/
|
| 36 |
+
[39m[33mSessionProvider[39m[33m>[39m
|
| 37 |
+
[90m 17 |[39m )[0m
|
| 38 |
+
|
| 39 |
+
at Resolver._throwModNotFoundError (node_modu
|
| 40 |
+
les/jest-resolve/build/index.js:863:11)
|
| 41 |
+
at Object.<anonymous> (node_modules/@testing-
|
| 42 |
+
library/react/dist/pure.js:46:12)
|
| 43 |
+
at Object.<anonymous> (node_modules/@testing-
|
| 44 |
+
library/react/dist/index.js:7:13)
|
| 45 |
+
at Object.<anonymous> (src/tests/Navbar.test.
|
| 46 |
+
tsx:14:16)
|
| 47 |
+
|
| 48 |
+
----------|---------|----------|---------|---------|-------------------
|
| 49 |
+
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
|
| 50 |
+
----------|---------|----------|---------|---------|-------------------
|
| 51 |
+
All files | 0 | 0 | 0 | 0 |
|
| 52 |
+
----------|---------|----------|---------|---------|-------------------
|
| 53 |
+
Test Suites: 1 failed, 1 total
|
| 54 |
+
Tests: 0 total
|
| 55 |
+
Snapshots: 0 total
|
| 56 |
+
Time: 1.233 s
|
| 57 |
+
Ran all test suites matching src/tests/Navbar.test.
|
| 58 |
+
tsx.
|
tests/test_api_local.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
def test_health():
|
| 4 |
+
try:
|
| 5 |
+
response = requests.get("http://localhost:7860/")
|
| 6 |
+
print(f"Health check: {response.json()}")
|
| 7 |
+
except Exception as e:
|
| 8 |
+
print(f"Server not running or error: {e}")
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
test_health()
|
tsconfig.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"lib": [
|
| 4 |
+
"dom",
|
| 5 |
+
"dom.iterable",
|
| 6 |
+
"esnext"
|
| 7 |
+
],
|
| 8 |
+
"allowJs": true,
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
"strict": true,
|
| 11 |
+
"noEmit": true,
|
| 12 |
+
"esModuleInterop": true,
|
| 13 |
+
"module": "esnext",
|
| 14 |
+
"moduleResolution": "bundler",
|
| 15 |
+
"resolveJsonModule": true,
|
| 16 |
+
"isolatedModules": true,
|
| 17 |
+
"jsx": "preserve",
|
| 18 |
+
"incremental": true,
|
| 19 |
+
"plugins": [
|
| 20 |
+
{
|
| 21 |
+
"name": "next"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"paths": {
|
| 25 |
+
"@/*": [
|
| 26 |
+
"./src/*"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
"types": [
|
| 30 |
+
"@react-three/fiber",
|
| 31 |
+
"jest"
|
| 32 |
+
],
|
| 33 |
+
"target": "ES2017"
|
| 34 |
+
},
|
| 35 |
+
"include": [
|
| 36 |
+
"next-env.d.ts",
|
| 37 |
+
"**/*.ts",
|
| 38 |
+
"**/*.tsx",
|
| 39 |
+
".next/types/**/*.ts"
|
| 40 |
+
],
|
| 41 |
+
"exclude": [
|
| 42 |
+
"node_modules"
|
| 43 |
+
]
|
| 44 |
+
}
|