Indrajit Ari commited on
Commit
87a48cb
·
1 Parent(s): 93108c0

fix: unified deployment fix with flat routing, Suspense boundaries, and forced static export

Browse files
Dockerfile CHANGED
@@ -12,14 +12,13 @@ FROM node:20-slim AS frontend-builder
12
 
13
  WORKDIR /build/frontend
14
  COPY frontend/package*.json ./
15
- RUN npm ci
 
16
 
17
  COPY frontend/ ./
18
- # Empty API URL NEXT_PUBLIC_API_URL="" means relative /api/* calls
19
- # which FastAPI will handle directly (same-origin)
20
  ENV NEXT_PUBLIC_API_URL=""
21
- # Trigger output: 'export' mode in next.config.js
22
- ENV BUILD_EXPORT=1
23
  RUN npm run build
24
 
25
  # ── Stage 2: Runtime (Python only, no nginx, no Node) ───────────────────────
 
12
 
13
  WORKDIR /build/frontend
14
  COPY frontend/package*.json ./
15
+ # use npm install (more resilient) instead of ci for this setup
16
+ RUN npm install
17
 
18
  COPY frontend/ ./
19
+ # We forced output:export in next.config.js, but keeping these for clarity
 
20
  ENV NEXT_PUBLIC_API_URL=""
21
+ ENV NODE_ENV=production
 
22
  RUN npm run build
23
 
24
  # ── Stage 2: Runtime (Python only, no nginx, no Node) ───────────────────────
backend/app_hf.py CHANGED
@@ -197,27 +197,31 @@ if STATIC_DIR.exists():
197
  async def serve_spa(full_path: str):
198
  """
199
  SPA catch-all: try to serve the exact static file, then .html,
200
- then fall back to index.html so client-side routing works.
201
  """
202
- # Exact file match (images, etc.)
 
 
 
 
 
 
203
  candidate = STATIC_DIR / full_path
204
  if candidate.is_file():
205
  return FileResponse(str(candidate))
206
 
207
- # Next.js static export adds .html per route
208
  html_candidate = STATIC_DIR / f"{full_path}.html"
209
  if html_candidate.is_file():
210
  return FileResponse(str(html_candidate))
211
 
212
- # For dynamic segments like /processing/[id], Next.js generates
213
- # processing/[id].html look for that pattern
214
- parts = full_path.split("/")
215
- if len(parts) == 2:
216
- segment_html = STATIC_DIR / parts[0] / "[id].html"
217
- if segment_html.is_file():
218
- return FileResponse(str(segment_html))
219
 
220
- # Final fallback: root index.html (SPA entry)
221
  index = STATIC_DIR / "index.html"
222
  if index.is_file():
223
  return FileResponse(str(index))
 
197
  async def serve_spa(full_path: str):
198
  """
199
  SPA catch-all: try to serve the exact static file, then .html,
200
+ then index.html in the folder (trailingSlash support).
201
  """
202
+ # Handle root specially
203
+ if not full_path or full_path == "/":
204
+ index = STATIC_DIR / "index.html"
205
+ if index.is_file(): return FileResponse(str(index))
206
+ return JSONResponse({"error": "frontend index.html not found"}, status_code=404)
207
+
208
+ # 1. Exact file match (images, JS, CSS)
209
  candidate = STATIC_DIR / full_path
210
  if candidate.is_file():
211
  return FileResponse(str(candidate))
212
 
213
+ # 2. Next.js route: try path.html (e.g., /upload -> upload.html)
214
  html_candidate = STATIC_DIR / f"{full_path}.html"
215
  if html_candidate.is_file():
216
  return FileResponse(str(html_candidate))
217
 
218
+ # 3. Next.js route with trailingSlash: path/index.html
219
+ # (e.g., /processing/ -> processing/index.html)
220
+ index_candidate = STATIC_DIR / full_path / "index.html"
221
+ if index_candidate.is_file():
222
+ return FileResponse(str(index_candidate))
 
 
223
 
224
+ # Final fallback: root index.html (client-side routing)
225
  index = STATIC_DIR / "index.html"
226
  if index.is_file():
227
  return FileResponse(str(index))
frontend/next.config.js CHANGED
@@ -5,19 +5,14 @@ const nextConfig = {
5
  },
6
 
7
  // Docker/HF Spaces: static export served by FastAPI directly on :7860
8
- ...(process.env.BUILD_EXPORT === '1' ? {
9
- output: 'export',
10
- eslint: { ignoreDuringBuilds: true },
11
- typescript: { ignoreBuildErrors: true },
12
- images: { unoptimized: true },
13
- } : {}),
14
 
15
- // Standalone build (not currently used but kept for reference)
16
- ...(process.env.BUILD_STANDALONE === '1' ? {
17
- output: 'standalone',
18
- eslint: { ignoreDuringBuilds: true },
19
- typescript: { ignoreBuildErrors: true },
20
- } : {}),
21
  }
22
 
23
  module.exports = nextConfig
 
5
  },
6
 
7
  // Docker/HF Spaces: static export served by FastAPI directly on :7860
8
+ output: 'export',
9
+ eslint: { ignoreDuringBuilds: true },
10
+ typescript: { ignoreBuildErrors: true },
11
+ images: { unoptimized: true },
 
 
12
 
13
+ // Ensure trailing slashes are consistent for static routing
14
+ // and Next.js generates processing/index.html instead of processing.html
15
+ trailingSlash: true,
 
 
 
16
  }
17
 
18
  module.exports = nextConfig
frontend/src/app/page.tsx CHANGED
@@ -109,7 +109,7 @@ export default function HomePage() {
109
  const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: form })
110
  if (!res.ok) { const d = await res.json(); throw new Error(d.detail ?? 'Upload failed') }
111
  const data = await res.json()
112
- router.push(`/processing/${data.job_id}`)
113
  } catch (e: any) {
114
  setError(e.message ?? 'Upload failed. Is the backend running?')
115
  setUploading(false)
 
109
  const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: form })
110
  if (!res.ok) { const d = await res.json(); throw new Error(d.detail ?? 'Upload failed') }
111
  const data = await res.json()
112
+ router.push(`/processing?id=${data.job_id}`)
113
  } catch (e: any) {
114
  setError(e.message ?? 'Upload failed. Is the backend running?')
115
  setUploading(false)
frontend/src/app/processing/[id]/client.tsx DELETED
@@ -1,223 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useState, useRef } from 'react'
4
- import { useRouter, useParams } from 'next/navigation'
5
-
6
- const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
-
8
- const VOC_COLORS: Record<string, string> = {
9
- aeroplane:'#87CEEB', bicycle:'#FFA500', bird:'#FFD700', boat:'#00BFFF',
10
- bottle:'#9400D3', bus:'#FF1493', car:'#DC143C', cat:'#FF8C00',
11
- chair:'#8B4513', cow:'#D4A017', diningtable:'#D2691E', dog:'#BA55D3',
12
- horse:'#FF69B4', motorbike:'#22c55e', person:'#FF4500',
13
- 'potted plant':'#228B22', sheep:'#B8A40A', sofa:'#00CED1',
14
- train:'#3b82f6', 'tv/monitor':'#0D9488',
15
- }
16
-
17
- const STEPS = ['Queued', 'Inferring Frames', 'Encoding H.264', 'Complete']
18
-
19
- export default function ProcessingPage() {
20
- const router = useRouter()
21
- const params = useParams()
22
- const jobId = params?.id as string
23
- const cardRef = useRef<HTMLDivElement>(null)
24
-
25
- const [pct, setPct] = useState(0)
26
- const [status, setStatus] = useState('queued')
27
- const [detected, setDetected] = useState<string[]>([])
28
- const [error, setError] = useState<string | null>(null)
29
- const [elapsed, setElapsed] = useState(0)
30
- const timerRef = useRef<NodeJS.Timeout | null>(null)
31
-
32
- useEffect(() => {
33
- // Animate card in
34
- setTimeout(() => cardRef.current?.classList.add('scroll-visible'), 50)
35
- }, [])
36
-
37
- useEffect(() => {
38
- if (!jobId) return
39
- const start = Date.now()
40
- timerRef.current = setInterval(() => setElapsed(Math.floor((Date.now()-start)/1000)), 1000)
41
-
42
- const wsUrl = `${API_BASE.replace('https','wss').replace('http','ws')}/ws/${jobId}`
43
- const ws = new WebSocket(wsUrl)
44
-
45
- ws.onmessage = (evt) => {
46
- const data = JSON.parse(evt.data)
47
- setStatus(data.status)
48
- if (data.pct !== undefined) setPct(data.pct)
49
- if (data.detected) setDetected(data.detected)
50
- if (data.status === 'done') {
51
- setPct(100); clearInterval(timerRef.current!)
52
- setTimeout(() => router.push(`/result/${jobId}`), 1200)
53
- }
54
- if (data.status === 'error') { setError(data.error ?? 'Failed'); clearInterval(timerRef.current!) }
55
- }
56
- ws.onerror = () => pollFallback()
57
- return () => { ws.close(); clearInterval(timerRef.current!) }
58
- }, [jobId])
59
-
60
- const pollFallback = () => {
61
- const iv = setInterval(async () => {
62
- try {
63
- const d = await fetch(`${API_BASE}/api/status/${jobId}`).then(r=>r.json())
64
- setStatus(d.status)
65
- if (d.pct !== undefined) setPct(d.pct)
66
- if (d.detected) setDetected(d.detected)
67
- if (d.status === 'done') {
68
- clearInterval(iv); clearInterval(timerRef.current!)
69
- setTimeout(() => router.push(`/result/${jobId}`), 1200)
70
- }
71
- if (d.status === 'error') { setError(d.error); clearInterval(iv) }
72
- } catch {}
73
- }, 1200)
74
- }
75
-
76
- const fmtTime = (s: number) => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`
77
- const currentStep = status==='queued' ? 0 : status==='processing' ? 1 : status==='done' ? 3 : 2
78
-
79
- return (
80
- <div className="max-w-xl mx-auto px-5 py-20">
81
- <div
82
- ref={cardRef}
83
- className="scroll-hidden card p-8 border border-slate-200 shadow-sm"
84
- style={{ borderRadius: '20px' }}
85
- >
86
- {/* Head */}
87
- <div className="text-center mb-8">
88
- {/* Icon */}
89
- <div className={`w-20 h-20 rounded-2xl mx-auto mb-5 flex items-center justify-center
90
- ${status==='done' ? 'bg-green-50 border border-green-200'
91
- : status==='error' ? 'bg-red-50 border border-red-200'
92
- : 'bg-orange-50 border border-orange-200'}`}>
93
- {status==='done' ? (
94
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
95
- ) : status==='error' ? (
96
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
97
- <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
98
- </svg>
99
- ) : (
100
- <svg className="animate-spin" width="30" height="30" viewBox="0 0 24 24" fill="none" strokeWidth="2">
101
- <defs>
102
- <linearGradient id="spin-g" x1="0%" y1="0%" x2="100%" y2="100%">
103
- <stop offset="0%" stopColor="#f97316"/>
104
- <stop offset="100%" stopColor="#fbbf24"/>
105
- </linearGradient>
106
- </defs>
107
- <path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#spin-g)"/>
108
- </svg>
109
- )}
110
- </div>
111
-
112
- <h1 className="text-xl font-bold text-slate-900 mb-1">
113
- {status==='queued' ? 'In Queue'
114
- : status==='processing' ? 'Segmenting…'
115
- : status==='done' ? 'Complete!'
116
- : status==='error' ? 'Failed' : status}
117
- </h1>
118
- <p className="text-sm text-slate-400">
119
- Job <code className="text-orange-500 font-mono text-xs">{jobId?.slice(0,8)}…</code>
120
- {status==='processing' && <span className="ml-2 text-slate-400">· {fmtTime(elapsed)}</span>}
121
- </p>
122
- </div>
123
-
124
- {/* Progress */}
125
- {status !== 'error' && (
126
- <div className="mb-7">
127
- <div className="flex justify-between text-xs font-medium text-slate-500 mb-2">
128
- <span>Progress</span>
129
- <span className={pct>=100 ? 'text-green-600' : 'text-orange-500'}>{pct.toFixed(1)}%</span>
130
- </div>
131
- <div className="progress-track h-2">
132
- <div className="progress-fill h-full" style={{ width:`${pct}%` }} />
133
- </div>
134
- </div>
135
- )}
136
-
137
- {/* Steps */}
138
- {status !== 'error' && (
139
- <div className="mb-7">
140
- <div className="flex items-center gap-0">
141
- {STEPS.map((s, i) => (
142
- <div key={i} className="flex items-center flex-1">
143
- <div className="flex flex-col items-center gap-1">
144
- <div className={`step-dot ${i < currentStep ? 'done' : i === currentStep ? 'active' : 'pending'}`}>
145
- {i < currentStep
146
- ? <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#15803d" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
147
- : i+1}
148
- </div>
149
- <p className={`text-[9px] font-semibold uppercase tracking-wider whitespace-nowrap
150
- ${i===currentStep ? 'text-orange-500' : i<currentStep ? 'text-green-600' : 'text-slate-300'}`}>
151
- {s}
152
- </p>
153
- </div>
154
- {i < STEPS.length-1 && (
155
- <div className={`h-px flex-1 mx-1 mb-4 ${i < currentStep ? 'bg-green-300' : 'bg-slate-200'}`} />
156
- )}
157
- </div>
158
- ))}
159
- </div>
160
- </div>
161
- )}
162
-
163
- {/* Error */}
164
- {error && (
165
- <div className="p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm mb-6">
166
- <strong>Error:</strong> {error}
167
- </div>
168
- )}
169
-
170
- {/* Stats */}
171
- {status === 'processing' && (
172
- <div className="grid grid-cols-3 gap-3 mb-7">
173
- {[
174
- { label:'Progress', val:`${pct.toFixed(0)}%`, color:'text-orange-500' },
175
- { label:'Objects', val:`${detected.length}`, color:'text-slate-800' },
176
- { label:'Elapsed', val:fmtTime(elapsed), color:'text-slate-800' },
177
- ].map(s => (
178
- <div key={s.label} className="text-center p-4 rounded-xl bg-slate-50 border border-slate-100">
179
- <p className="text-[10px] text-slate-400 uppercase tracking-widest mb-1">{s.label}</p>
180
- <p className={`text-lg font-bold ${s.color}`} style={{fontVariantNumeric:'tabular-nums'}}>{s.val}</p>
181
- </div>
182
- ))}
183
- </div>
184
- )}
185
-
186
- {/* Queue dots */}
187
- {status === 'queued' && (
188
- <div className="flex items-center gap-3 p-4 rounded-xl bg-orange-50 border border-orange-100 mb-6">
189
- <div className="flex gap-1.5">
190
- <span className="bounce-dot bg-orange-400" />
191
- <span className="bounce-dot bg-amber-400" />
192
- <span className="bounce-dot bg-yellow-400" />
193
- </div>
194
- <p className="text-sm text-orange-700">Waiting for a worker to pick up this job…</p>
195
- </div>
196
- )}
197
-
198
- {/* Detected classes */}
199
- {detected.length > 0 && (
200
- <div className="pt-4 border-t border-slate-100">
201
- <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">
202
- Detected Objects · {detected.length}
203
- </p>
204
- <div className="flex flex-wrap gap-1.5">
205
- {detected.map(cls => (
206
- <span key={cls} className="class-pill">
207
- <span className="w-2 h-2 rounded-full" style={{ backgroundColor: VOC_COLORS[cls]??'#888' }} />
208
- {cls}
209
- </span>
210
- ))}
211
- </div>
212
- </div>
213
- )}
214
-
215
- {/* Back link */}
216
- <a href="/" className="mt-8 flex items-center justify-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors">
217
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
218
- Back to upload
219
- </a>
220
- </div>
221
- </div>
222
- )
223
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/app/processing/[id]/page.tsx DELETED
@@ -1,15 +0,0 @@
1
- // Server component — can export generateStaticParams (required for output: 'export')
2
- // The actual UI lives in client.tsx which is a 'use client' component.
3
-
4
- import ProcessingClient from './client'
5
-
6
- // Return empty array: no paths are pre-rendered at build time.
7
- // The static export still emits processing/[id]/index.html as a shell;
8
- // routing is handled client-side via app_hf.py SPA fallback.
9
- export function generateStaticParams() {
10
- return []
11
- }
12
-
13
- export default function ProcessingPage() {
14
- return <ProcessingClient />
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/app/result/[id]/client.tsx DELETED
@@ -1,274 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useState, useRef } from 'react'
4
- import { useParams } from 'next/navigation'
5
-
6
- const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
-
8
- const VOC_COLORS: Record<string, string> = {
9
- aeroplane:'#87CEEB', bicycle:'#FFA500', bird:'#FFD700', boat:'#00BFFF',
10
- bottle:'#9400D3', bus:'#FF1493', car:'#DC143C', cat:'#FF8C00',
11
- chair:'#8B4513', cow:'#D4A017', diningtable:'#D2691E', dog:'#BA55D3',
12
- horse:'#FF69B4', motorbike:'#22c55e', person:'#FF4500',
13
- 'potted plant':'#228B22', sheep:'#B8A40A', sofa:'#00CED1',
14
- train:'#3b82f6', 'tv/monitor':'#0D9488',
15
- }
16
-
17
- function useScrollReveal(status: string) {
18
- useEffect(() => {
19
- if (status !== 'ready') return
20
- // Small delay to ensure the DOM has fully updated
21
- const timer = setTimeout(() => {
22
- const targets = document.querySelectorAll('.scroll-hidden, .scroll-left, .scroll-right, .scroll-scale')
23
- const obs = new IntersectionObserver(
24
- entries => entries.forEach(e => {
25
- if (e.isIntersecting) {
26
- e.target.classList.add('scroll-visible')
27
- obs.unobserve(e.target)
28
- }
29
- }),
30
- { threshold: 0.05 }
31
- )
32
- targets.forEach(t => obs.observe(t))
33
- }, 100)
34
- return () => clearTimeout(timer)
35
- }, [status])
36
- }
37
-
38
- export default function ResultPage() {
39
- const params = useParams()
40
- const jobId = params?.id as string
41
- const videoRef = useRef<HTMLVideoElement>(null)
42
-
43
- // 'loading' → 'ready' or 'error'
44
- const [status, setStatus] = useState<'loading'|'ready'|'error'>('loading')
45
- const [detected, setDetected] = useState<string[]>([])
46
- const [videoReady, setVideoReady] = useState(false) // video URL responded 200
47
- const [isPlaying, setIsPlaying] = useState(false)
48
- const [currentTime, setCurrentTime] = useState(0)
49
- const [duration, setDuration] = useState(0)
50
- const [volume, setVolume] = useState(1)
51
- const [copied, setCopied] = useState(false)
52
- const [retries, setRetries] = useState(0)
53
-
54
- const videoUrl = `${API_BASE}/api/video/${jobId}`
55
-
56
- useScrollReveal(status)
57
-
58
- useEffect(() => {
59
- if (!jobId) return
60
-
61
- const fetchStatus = async () => {
62
- try {
63
- const res = await fetch(`${API_BASE}/api/status/${jobId}`)
64
- if (!res.ok) throw new Error()
65
- const data = await res.json()
66
-
67
- if (data.status === 'done') {
68
- setDetected(data.detected ?? [])
69
- setStatus('ready')
70
- return
71
- }
72
- await probeVideo()
73
- } catch {
74
- await probeVideo()
75
- }
76
- }
77
-
78
- const probeVideo = async () => {
79
- try {
80
- // Explicitly use HEAD; backend now supports this
81
- const res = await fetch(videoUrl, { method: 'HEAD' })
82
- if (res.ok) {
83
- setStatus('ready')
84
- } else if (retries < 6) {
85
- setTimeout(() => setRetries(r => r + 1), 1500)
86
- } else {
87
- setStatus('error')
88
- }
89
- } catch {
90
- setStatus('error')
91
- }
92
- }
93
-
94
- fetchStatus()
95
- }, [jobId, retries, videoUrl])
96
-
97
- const togglePlay = () => {
98
- const v = videoRef.current; if (!v) return
99
- if (v.paused) { v.play(); setIsPlaying(true) }
100
- else { v.pause(); setIsPlaying(false) }
101
- }
102
-
103
- const fmtTime = (s: number) =>
104
- `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
105
-
106
- const copyLink = async () => {
107
- await navigator.clipboard.writeText(window.location.href)
108
- setCopied(true)
109
- setTimeout(() => setCopied(false), 2000)
110
- }
111
-
112
- if (status === 'loading') return (
113
- <div className="max-w-4xl mx-auto px-5 py-32 text-center">
114
- <div className="w-16 h-16 rounded-2xl bg-orange-50 border border-orange-200 flex items-center justify-center mx-auto mb-5">
115
- <svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24" fill="none" strokeWidth="2">
116
- <defs>
117
- <linearGradient id="sg-spin" x1="0%" y1="0%" x2="100%" y2="100%">
118
- <stop offset="0%" stopColor="#f97316"/>
119
- <stop offset="100%" stopColor="#fbbf24"/>
120
- </linearGradient>
121
- </defs>
122
- <path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#sg-spin)"/>
123
- </svg>
124
- </div>
125
- <p className="text-sm font-medium text-slate-600">Loading your result…</p>
126
- {retries > 0 && (
127
- <p className="text-xs text-slate-400 mt-2">Connecting… (Attempt {retries})</p>
128
- )}
129
- </div>
130
- )
131
-
132
- if (status === 'error') return (
133
- <div className="max-w-4xl mx-auto px-5 py-32 text-center">
134
- <div className="w-16 h-16 rounded-2xl bg-red-50 border border-red-200 flex items-center justify-center mx-auto mb-5">
135
- <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
136
- <circle cx="12" cy="12" r="10"/>
137
- <line x1="15" y1="9" x2="9" y2="15"/>
138
- <line x1="9" y1="9" x2="15" y2="15"/>
139
- </svg>
140
- </div>
141
- <p className="font-semibold text-slate-800 mb-1">Result not available</p>
142
- <p className="text-sm text-slate-500 mb-6">The job might still be processing or the file has expired.</p>
143
- <div className="flex items-center justify-center gap-3">
144
- <button onClick={() => { setStatus('loading'); setRetries(0) }} className="btn-outline px-5 py-2.5 text-sm">Retry</button>
145
- <a href="/" className="btn-primary px-5 py-2.5 text-sm">New Upload</a>
146
- </div>
147
- </div>
148
- )
149
-
150
- return (
151
- <div className="bg-white max-w-5xl mx-auto px-5 py-12">
152
- {/* Header — No animation for immediate layout stability */}
153
- <div className="flex items-start justify-between mb-10 flex-wrap gap-4">
154
- <div>
155
- <div className="flex items-center gap-2 mb-2">
156
- <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
157
- <span className="text-xs font-bold text-green-600 uppercase tracking-widest">Segmentation Finished</span>
158
- </div>
159
- <h1 className="text-3xl font-bold text-slate-900 tracking-tight">Your AI Result</h1>
160
- <p className="text-sm text-slate-500 mt-1">
161
- Job ID: <code className="text-orange-500 font-mono">{jobId?.slice(0, 12)}</code>
162
- </p>
163
- </div>
164
-
165
- <div className="flex gap-2">
166
- <button onClick={copyLink} className="btn-outline px-4 py-2.5 text-sm flex items-center gap-2">
167
- {copied ? 'Link Copied!' : 'Copy Result Link'}
168
- </button>
169
- <a href={videoUrl} download className="btn-primary px-5 py-2.5 text-sm flex items-center gap-2">
170
- Download MP4
171
- </a>
172
- </div>
173
- </div>
174
-
175
- {/* Video Player Card */}
176
- <div className="card border border-slate-200 overflow-hidden mb-8 scroll-scale">
177
- <div className="flex border-b border-slate-100 bg-slate-50/50">
178
- <div className="flex-1 py-3 text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest border-r border-slate-100">Original</div>
179
- <div className="flex-1 py-3 text-center text-[10px] font-bold text-orange-500 uppercase tracking-widest">Segmented Overlay</div>
180
- </div>
181
-
182
- <div className="bg-black relative aspect-video">
183
- <video
184
- ref={videoRef}
185
- src={videoUrl}
186
- className="w-full h-full"
187
- playsInline
188
- preload="auto"
189
- onTimeUpdate={() => videoRef.current && setCurrentTime(videoRef.current.currentTime)}
190
- onLoadedMetadata={() => {
191
- if (videoRef.current) {
192
- setDuration(videoRef.current.duration)
193
- setVideoReady(true)
194
- }
195
- }}
196
- onEnded={() => setIsPlaying(false)}
197
- onError={() => setTimeout(() => setRetries(r => r + 1), 2000)}
198
- />
199
- {!videoReady && (
200
- <div className="absolute inset-0 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm">
201
- <div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
202
- </div>
203
- )}
204
- </div>
205
-
206
- <div className="px-6 py-5 bg-slate-50 border-t border-slate-100">
207
- <div className="flex items-center gap-4 mb-4">
208
- <span className="text-xs text-slate-400 font-mono w-10">{fmtTime(currentTime)}</span>
209
- <div className="flex-1 h-1.5 rounded-full bg-slate-200 relative group cursor-pointer">
210
- <div className="h-full rounded-full bg-gradient-to-r from-orange-500 to-amber-400" style={{ width: `${(currentTime/duration)*100}%` }} />
211
- <input
212
- type="range" min={0} max={duration || 1} step={0.1} value={currentTime}
213
- onChange={e => {
214
- const t = +e.target.value
215
- if (videoRef.current) { videoRef.current.currentTime = t; setCurrentTime(t) }
216
- }}
217
- className="absolute inset-0 w-full opacity-0 cursor-pointer"
218
- />
219
- </div>
220
- <span className="text-xs text-slate-400 font-mono w-10 text-right">{fmtTime(duration)}</span>
221
- </div>
222
-
223
- <div className="flex items-center justify-between">
224
- <div className="flex items-center gap-4">
225
- <button onClick={togglePlay} className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center hover:bg-slate-800 transition-all active:scale-95 shadow-lg">
226
- {isPlaying ? <svg width="18" height="18" viewBox="0 0 24 24" fill="white"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> : <svg width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5 3 19 12 5 21 5 3"/></svg>}
227
- </button>
228
- <div className="flex items-center gap-2 group">
229
- <div className="w-8 h-8 rounded-lg bg-orange-100 flex items-center justify-center">
230
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
231
- </div>
232
- <input
233
- type="range" min={0} max={1} step={0.05} value={volume}
234
- onChange={e => {
235
- const v = +e.target.value
236
- if (videoRef.current) videoRef.current.volume = v
237
- setVolume(v)
238
- }}
239
- className="w-24 h-1.5"
240
- />
241
- </div>
242
- </div>
243
- <div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">H.264 High Profile · 30 FPS</div>
244
- </div>
245
- </div>
246
- </div>
247
-
248
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
249
- <div className="md:col-span-2 card p-8 scroll-hidden">
250
- <div className="flex items-center justify-between mb-6">
251
- <h3 className="text-sm font-bold text-slate-800 uppercase tracking-widest">AI Detections</h3>
252
- <span className="badge">{detected.length} Objects</span>
253
- </div>
254
- <div className="flex flex-wrap gap-2">
255
- {detected.length > 0 ? detected.map(cls => (
256
- <span key={cls} className="class-pill">
257
- <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: VOC_COLORS[cls] ?? '#888' }} />
258
- {cls}
259
- </span>
260
- )) : <span className="text-sm text-slate-400">Processing detailed labels...</span>}
261
- </div>
262
- </div>
263
-
264
- <div className="card p-8 bg-slate-900 border-slate-800 text-white scroll-hidden">
265
- <h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Quick Actions</h3>
266
- <div className="space-y-3">
267
- <a href="/" className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-white/10 hover:bg-white/20 text-sm font-medium transition-all">New Segmentation</a>
268
- <a href={videoUrl} download className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-orange-500 hover:bg-orange-600 text-sm font-bold transition-all shadow-lg shadow-orange-500/20">Save Result</a>
269
- </div>
270
- </div>
271
- </div>
272
- </div>
273
- )
274
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/app/result/[id]/page.tsx DELETED
@@ -1,15 +0,0 @@
1
- // Server component — can export generateStaticParams (required for output: 'export')
2
- // The actual UI lives in client.tsx which is a 'use client' component.
3
-
4
- import ResultClient from './client'
5
-
6
- // Return empty array: no paths are pre-rendered at build time.
7
- // The static export still emits result/[id]/index.html as a shell;
8
- // routing is handled client-side via app_hf.py SPA fallback.
9
- export function generateStaticParams() {
10
- return []
11
- }
12
-
13
- export default function ResultPage() {
14
- return <ResultClient />
15
- }