Spaces:
Sleeping
Sleeping
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 |
-
|
|
|
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import { useRouter, useParams } from 'next/navigation'
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import { useParams } from 'next/navigation'
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 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 |
}
|