Indrajit Ari commited on
Commit
76b632a
·
1 Parent(s): 62b7f0a

fix: split dynamic routes into server+client components for static export

Browse files
frontend/src/app/processing/[id]/client.tsx ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 CHANGED
@@ -1,223 +1,15 @@
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
  }
 
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 ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 CHANGED
@@ -1,274 +1,15 @@
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
  }
 
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
  }