Spaces:
Sleeping
Sleeping
Indrajit Ari commited on
Commit ·
93108c0
1
Parent(s): 76b632a
fix: restore full content and wrap in Suspense for static build
Browse files- frontend/src/app/processing/page.tsx +221 -0
- frontend/src/app/result/page.tsx +198 -0
frontend/src/app/processing/page.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useRef, Suspense } from 'react'
|
| 4 |
+
import { useRouter, useSearchParams } 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 |
+
function ProcessingContent() {
|
| 20 |
+
const router = useRouter()
|
| 21 |
+
const searchParams = useSearchParams()
|
| 22 |
+
const jobId = searchParams?.get('id') ?? ''
|
| 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<ReturnType<typeof setInterval> | null>(null)
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
setTimeout(() => cardRef.current?.classList.add('scroll-visible'), 50)
|
| 34 |
+
}, [])
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!jobId) return
|
| 38 |
+
const start = Date.now()
|
| 39 |
+
timerRef.current = setInterval(() => setElapsed(Math.floor((Date.now()-start)/1000)), 1000)
|
| 40 |
+
|
| 41 |
+
const wsUrl = `${API_BASE.replace('https','wss').replace('http','ws')}/ws/${jobId}`
|
| 42 |
+
const ws = new WebSocket(wsUrl)
|
| 43 |
+
|
| 44 |
+
ws.onmessage = (evt) => {
|
| 45 |
+
const data = JSON.parse(evt.data)
|
| 46 |
+
setStatus(data.status)
|
| 47 |
+
if (data.pct !== undefined) setPct(data.pct)
|
| 48 |
+
if (data.detected) setDetected(data.detected)
|
| 49 |
+
if (data.status === 'done') {
|
| 50 |
+
setPct(100); clearInterval(timerRef.current!)
|
| 51 |
+
setTimeout(() => router.push(`/result?id=${jobId}`), 1200)
|
| 52 |
+
}
|
| 53 |
+
if (data.status === 'error') { setError(data.error ?? 'Failed'); clearInterval(timerRef.current!) }
|
| 54 |
+
}
|
| 55 |
+
ws.onerror = () => pollFallback()
|
| 56 |
+
return () => { ws.close(); clearInterval(timerRef.current!) }
|
| 57 |
+
}, [jobId, router])
|
| 58 |
+
|
| 59 |
+
const pollFallback = () => {
|
| 60 |
+
const iv = setInterval(async () => {
|
| 61 |
+
try {
|
| 62 |
+
const d = await fetch(`${API_BASE}/api/status/${jobId}`).then(r=>r.json())
|
| 63 |
+
setStatus(d.status)
|
| 64 |
+
if (d.pct !== undefined) setPct(d.pct)
|
| 65 |
+
if (d.detected) setDetected(d.detected)
|
| 66 |
+
if (d.status === 'done') {
|
| 67 |
+
clearInterval(iv); clearInterval(timerRef.current!)
|
| 68 |
+
setTimeout(() => router.push(`/result?id=${jobId}`), 1200)
|
| 69 |
+
}
|
| 70 |
+
if (d.status === 'error') { setError(d.error); clearInterval(iv) }
|
| 71 |
+
} catch {}
|
| 72 |
+
}, 1200)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const fmtTime = (s: number) => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`
|
| 76 |
+
const currentStep = status==='queued' ? 0 : status==='processing' ? 1 : status==='done' ? 3 : 2
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="max-w-xl mx-auto px-5 py-20">
|
| 80 |
+
<div
|
| 81 |
+
ref={cardRef}
|
| 82 |
+
className="scroll-hidden card p-8 border border-slate-200 shadow-sm"
|
| 83 |
+
style={{ borderRadius: '20px' }}
|
| 84 |
+
>
|
| 85 |
+
<div className="text-center mb-8">
|
| 86 |
+
<div className={`w-20 h-20 rounded-2xl mx-auto mb-5 flex items-center justify-center
|
| 87 |
+
${status==='done' ? 'bg-green-50 border border-green-200'
|
| 88 |
+
: status==='error' ? 'bg-red-50 border border-red-200'
|
| 89 |
+
: 'bg-orange-50 border border-orange-200'}`}>
|
| 90 |
+
{status==='done' ? (
|
| 91 |
+
<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>
|
| 92 |
+
) : status==='error' ? (
|
| 93 |
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
| 94 |
+
<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"/>
|
| 95 |
+
</svg>
|
| 96 |
+
) : (
|
| 97 |
+
<svg className="animate-spin" width="30" height="30" viewBox="0 0 24 24" fill="none" strokeWidth="2">
|
| 98 |
+
<defs>
|
| 99 |
+
<linearGradient id="spin-g" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 100 |
+
<stop offset="0%" stopColor="#f97316"/>
|
| 101 |
+
<stop offset="100%" stopColor="#fbbf24"/>
|
| 102 |
+
</linearGradient>
|
| 103 |
+
</defs>
|
| 104 |
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#spin-g)"/>
|
| 105 |
+
</svg>
|
| 106 |
+
)}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<h1 className="text-xl font-bold text-slate-900 mb-1">
|
| 110 |
+
{status==='queued' ? 'In Queue'
|
| 111 |
+
: status==='processing' ? 'Segmenting…'
|
| 112 |
+
: status==='done' ? 'Complete!'
|
| 113 |
+
: status==='error' ? 'Failed' : status}
|
| 114 |
+
</h1>
|
| 115 |
+
<p className="text-sm text-slate-400">
|
| 116 |
+
Job <code className="text-orange-500 font-mono text-xs">{jobId?.slice(0,8)}…</code>
|
| 117 |
+
{status==='processing' && <span className="ml-2 text-slate-400">· {fmtTime(elapsed)}</span>}
|
| 118 |
+
</p>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{status !== 'error' && (
|
| 122 |
+
<div className="mb-7">
|
| 123 |
+
<div className="flex justify-between text-xs font-medium text-slate-500 mb-2">
|
| 124 |
+
<span>Progress</span>
|
| 125 |
+
<span className={pct>=100 ? 'text-green-600' : 'text-orange-500'}>{pct.toFixed(1)}%</span>
|
| 126 |
+
</div>
|
| 127 |
+
<div className="progress-track h-2">
|
| 128 |
+
<div className="progress-fill h-full" style={{ width:`${pct}%` }} />
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{status !== 'error' && (
|
| 134 |
+
<div className="mb-7">
|
| 135 |
+
<div className="flex items-center gap-0">
|
| 136 |
+
{STEPS.map((s, i) => (
|
| 137 |
+
<div key={i} className="flex items-center flex-1">
|
| 138 |
+
<div className="flex flex-col items-center gap-1">
|
| 139 |
+
<div className={`step-dot ${i < currentStep ? 'done' : i === currentStep ? 'active' : 'pending'}`}>
|
| 140 |
+
{i < currentStep
|
| 141 |
+
? <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>
|
| 142 |
+
: i+1}
|
| 143 |
+
</div>
|
| 144 |
+
<p className={`text-[9px] font-semibold uppercase tracking-wider whitespace-nowrap
|
| 145 |
+
${i===currentStep ? 'text-orange-500' : i<currentStep ? 'text-green-600' : 'text-slate-300'}`}>
|
| 146 |
+
{s}
|
| 147 |
+
</p>
|
| 148 |
+
</div>
|
| 149 |
+
{i < STEPS.length-1 && (
|
| 150 |
+
<div className={`h-px flex-1 mx-1 mb-4 ${i < currentStep ? 'bg-green-300' : 'bg-slate-200'}`} />
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
))}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
|
| 158 |
+
{error && (
|
| 159 |
+
<div className="p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm mb-6">
|
| 160 |
+
<strong>Error:</strong> {error}
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
{status === 'processing' && (
|
| 165 |
+
<div className="grid grid-cols-3 gap-3 mb-7">
|
| 166 |
+
{[
|
| 167 |
+
{ label:'Progress', val:`${pct.toFixed(0)}%`, color:'text-orange-500' },
|
| 168 |
+
{ label:'Objects', val:`${detected.length}`, color:'text-slate-800' },
|
| 169 |
+
{ label:'Elapsed', val:fmtTime(elapsed), color:'text-slate-800' },
|
| 170 |
+
].map(s => (
|
| 171 |
+
<div key={s.label} className="text-center p-4 rounded-xl bg-slate-50 border border-slate-100">
|
| 172 |
+
<p className="text-[10px] text-slate-400 uppercase tracking-widest mb-1">{s.label}</p>
|
| 173 |
+
<p className={`text-lg font-bold ${s.color}`} style={{fontVariantNumeric:'tabular-nums'}}>{s.val}</p>
|
| 174 |
+
</div>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
|
| 179 |
+
{status === 'queued' && (
|
| 180 |
+
<div className="flex items-center gap-3 p-4 rounded-xl bg-orange-50 border border-orange-100 mb-6">
|
| 181 |
+
<div className="flex gap-1.5">
|
| 182 |
+
<span className="bounce-dot bg-orange-400" />
|
| 183 |
+
<span className="bounce-dot bg-amber-400" />
|
| 184 |
+
<span className="bounce-dot bg-yellow-400" />
|
| 185 |
+
</div>
|
| 186 |
+
<p className="text-sm text-orange-700">Waiting for a worker to pick up this job…</p>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
{detected.length > 0 && (
|
| 191 |
+
<div className="pt-4 border-t border-slate-100">
|
| 192 |
+
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">
|
| 193 |
+
Detected Objects · {detected.length}
|
| 194 |
+
</p>
|
| 195 |
+
<div className="flex flex-wrap gap-1.5">
|
| 196 |
+
{detected.map(cls => (
|
| 197 |
+
<span key={cls} className="class-pill">
|
| 198 |
+
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: VOC_COLORS[cls]??'#888' }} />
|
| 199 |
+
{cls}
|
| 200 |
+
</span>
|
| 201 |
+
))}
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
|
| 206 |
+
<a href="/" className="mt-8 flex items-center justify-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors">
|
| 207 |
+
<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>
|
| 208 |
+
Back to upload
|
| 209 |
+
</a>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
export default function ProcessingPage() {
|
| 216 |
+
return (
|
| 217 |
+
<Suspense fallback={<div className="p-20 text-center text-slate-400">Loading process…</div>}>
|
| 218 |
+
<ProcessingContent />
|
| 219 |
+
</Suspense>
|
| 220 |
+
)
|
| 221 |
+
}
|
frontend/src/app/result/page.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useRef, Suspense } from 'react'
|
| 4 |
+
import { useRouter, useSearchParams } from 'next/navigation'
|
| 5 |
+
|
| 6 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
|
| 7 |
+
|
| 8 |
+
function ResultContent() {
|
| 9 |
+
const router = useRouter()
|
| 10 |
+
const searchParams = useSearchParams()
|
| 11 |
+
const jobId = searchParams?.get('id') ?? ''
|
| 12 |
+
const videoRef = useRef<HTMLVideoElement>(null)
|
| 13 |
+
|
| 14 |
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
| 15 |
+
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
| 16 |
+
const [detected, setDetected] = useState<string[]>([])
|
| 17 |
+
const [isPlaying, setIsPlaying] = useState(false)
|
| 18 |
+
const [copied, setCopied] = useState(false)
|
| 19 |
+
const [retries, setRetries] = useState(0)
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
if (!jobId) { setStatus('error'); return }
|
| 23 |
+
|
| 24 |
+
const checkStatus = async () => {
|
| 25 |
+
try {
|
| 26 |
+
// 1. Check API status
|
| 27 |
+
const res = await fetch(`${API_BASE}/api/status/${jobId}`)
|
| 28 |
+
if (!res.ok) throw new Error('Not found')
|
| 29 |
+
const data = await res.json()
|
| 30 |
+
|
| 31 |
+
if (data.status === 'done') {
|
| 32 |
+
setDetected(data.detected || [])
|
| 33 |
+
const url = `${API_BASE}/api/video/${jobId}`
|
| 34 |
+
|
| 35 |
+
// 2. Verify file exists with HEAD request
|
| 36 |
+
const head = await fetch(url, { method: 'HEAD' })
|
| 37 |
+
if (head.ok) {
|
| 38 |
+
setVideoUrl(url)
|
| 39 |
+
setStatus('ready')
|
| 40 |
+
} else {
|
| 41 |
+
throw new Error('Video file missing')
|
| 42 |
+
}
|
| 43 |
+
} else if (data.status === 'error') {
|
| 44 |
+
setStatus('error')
|
| 45 |
+
} else {
|
| 46 |
+
// Still processing? Wait and retry
|
| 47 |
+
if (retries < 10) {
|
| 48 |
+
setTimeout(() => setRetries(r => r + 1), 2000)
|
| 49 |
+
} else {
|
| 50 |
+
setStatus('error')
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
} catch (err) {
|
| 54 |
+
if (retries < 5) {
|
| 55 |
+
setTimeout(() => setRetries(r => r + 1), 2000)
|
| 56 |
+
} else {
|
| 57 |
+
setStatus('error')
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
checkStatus()
|
| 63 |
+
}, [jobId, retries])
|
| 64 |
+
|
| 65 |
+
const togglePlay = () => {
|
| 66 |
+
const v = videoRef.current; if (!v) return
|
| 67 |
+
if (v.paused) { v.play(); setIsPlaying(true) }
|
| 68 |
+
else { v.pause(); setIsPlaying(false) }
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const fmtTime = (s: number) =>
|
| 72 |
+
`${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
| 73 |
+
|
| 74 |
+
const copyLink = async () => {
|
| 75 |
+
await navigator.clipboard.writeText(window.location.href)
|
| 76 |
+
setCopied(true)
|
| 77 |
+
setTimeout(() => setCopied(false), 2000)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if (status === 'loading') return (
|
| 81 |
+
<div className="max-w-4xl mx-auto px-5 py-32 text-center">
|
| 82 |
+
<div className="w-16 h-16 rounded-2xl bg-orange-50 border border-orange-200 flex items-center justify-center mx-auto mb-5">
|
| 83 |
+
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24" fill="none" strokeWidth="2">
|
| 84 |
+
<defs>
|
| 85 |
+
<linearGradient id="sg-spin" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 86 |
+
<stop offset="0%" stopColor="#f97316"/>
|
| 87 |
+
<stop offset="100%" stopColor="#fbbf24"/>
|
| 88 |
+
</linearGradient>
|
| 89 |
+
</defs>
|
| 90 |
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#sg-spin)"/>
|
| 91 |
+
</svg>
|
| 92 |
+
</div>
|
| 93 |
+
<p className="text-sm font-medium text-slate-600">Loading your result…</p>
|
| 94 |
+
</div>
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if (status === 'error') return (
|
| 98 |
+
<div className="max-w-4xl mx-auto px-5 py-32 text-center">
|
| 99 |
+
<div className="w-16 h-16 rounded-2xl bg-red-50 border border-red-200 flex items-center justify-center mx-auto mb-5">
|
| 100 |
+
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
| 101 |
+
<circle cx="12" cy="12" r="10"/>
|
| 102 |
+
<line x1="15" y1="9" x2="9" y2="15"/>
|
| 103 |
+
<line x1="9" y1="9" x2="15" y2="15"/>
|
| 104 |
+
</svg>
|
| 105 |
+
</div>
|
| 106 |
+
<p className="font-semibold text-slate-800 mb-1">Result not available</p>
|
| 107 |
+
<p className="text-sm text-slate-500 mb-6">The job might still be processing or the file has expired.</p>
|
| 108 |
+
<div className="flex items-center justify-center gap-3">
|
| 109 |
+
<button onClick={() => { setStatus('loading'); setRetries(0) }} className="btn-outline px-5 py-2.5 text-sm">Retry</button>
|
| 110 |
+
<a href="/" className="btn-primary px-5 py-2.5 text-sm">New Upload</a>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div className="bg-white max-w-5xl mx-auto px-5 py-12">
|
| 117 |
+
<div className="flex items-start justify-between mb-10 flex-wrap gap-4">
|
| 118 |
+
<div>
|
| 119 |
+
<div className="flex items-center gap-2 mb-2">
|
| 120 |
+
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
| 121 |
+
<span className="text-xs font-bold text-green-600 uppercase tracking-widest">Segmentation Finished</span>
|
| 122 |
+
</div>
|
| 123 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">Your AI Result</h1>
|
| 124 |
+
<p className="text-sm text-slate-500 mt-1">
|
| 125 |
+
Job ID: <code className="text-orange-500 font-mono">{jobId?.slice(0, 12)}</code>
|
| 126 |
+
</p>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className="flex gap-2">
|
| 130 |
+
<button onClick={copyLink} className="btn-outline px-4 py-2.5 text-sm flex items-center gap-2">
|
| 131 |
+
{copied ? 'Link Copied!' : 'Copy Result Link'}
|
| 132 |
+
</button>
|
| 133 |
+
<a href={videoUrl!} download className="btn-primary px-5 py-2.5 text-sm flex items-center gap-2">
|
| 134 |
+
Download MP4
|
| 135 |
+
</a>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div className="bg-slate-900 rounded-[32px] overflow-hidden shadow-2xl border border-slate-800 relative group aspect-video lg:aspect-auto">
|
| 140 |
+
<video
|
| 141 |
+
ref={videoRef}
|
| 142 |
+
src={videoUrl!}
|
| 143 |
+
className="w-full h-full max-h-[70vh]"
|
| 144 |
+
onPlay={() => setIsPlaying(true)}
|
| 145 |
+
onPause={() => setIsPlaying(false)}
|
| 146 |
+
onClick={togglePlay}
|
| 147 |
+
/>
|
| 148 |
+
|
| 149 |
+
<div className="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
| 150 |
+
<div className="flex items-center justify-between">
|
| 151 |
+
<button onClick={togglePlay} className="w-12 h-12 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center hover:bg-white/20 transition-all">
|
| 152 |
+
{isPlaying ? (
|
| 153 |
+
<svg width="20" height="20" 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>
|
| 154 |
+
) : (
|
| 155 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
| 156 |
+
)}
|
| 157 |
+
</button>
|
| 158 |
+
<div className="px-4 py-2 rounded-full bg-black/40 backdrop-blur-md border border-white/10 text-[10px] font-bold text-white uppercase tracking-widest">
|
| 159 |
+
DeepLabV3+ Output
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{detected.length > 0 && (
|
| 166 |
+
<div className="mt-12">
|
| 167 |
+
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
|
| 168 |
+
<span className="h-px bg-slate-100 flex-1" />
|
| 169 |
+
Detected Objects
|
| 170 |
+
<span className="h-px bg-slate-100 flex-1" />
|
| 171 |
+
</h2>
|
| 172 |
+
<div className="flex flex-wrap justify-center gap-2">
|
| 173 |
+
{detected.map(cls => (
|
| 174 |
+
<span key={cls} className="px-4 py-2 rounded-xl bg-slate-50 border border-slate-100 text-sm font-medium text-slate-700 capitalize">
|
| 175 |
+
{cls}
|
| 176 |
+
</span>
|
| 177 |
+
))}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
<div className="mt-16 text-center">
|
| 183 |
+
<a href="/" className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-orange-500 transition-colors">
|
| 184 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
| 185 |
+
Back to upload new video
|
| 186 |
+
</a>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
)
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export default function ResultPage() {
|
| 193 |
+
return (
|
| 194 |
+
<Suspense fallback={<div className="p-20 text-center">Loading...</div>}>
|
| 195 |
+
<ResultContent />
|
| 196 |
+
</Suspense>
|
| 197 |
+
)
|
| 198 |
+
}
|