GitHub Actions Bot commited on
Commit
dce7eca
·
0 Parent(s):

Sync: Thu Feb 12 07:00:42 UTC 2026

Browse files
.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
+   12 | it('renders logo
25
+ and sign in button', () =>
26
+ {
27
+  13 | render(
28
+ > 14 |
29
+ <SessionProvider session=
30
+ {null}>
31
+  | ^
32
+ 
33
+  15 | <[3
34
+ 3mNavbar />
35
+  16 | </
36
+ [39mSessionProvider>
37
+  17 | )
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
+ }