diff --git a/app/page.tsx b/app/page.tsx index dc47c9c..e6aa0a3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,105 +1,16 @@ "use client" -import { useState } from "react" -import { UploadSection } from "@/components/upload-section" -import VideoPlayerSection from "@/components/video-player-section" +import { useRouter } from "next/navigation" import { ProjectSelectionSection } from "@/components/project-selection-section" -import { type SessionContext, emptySessionContext } from "@/lib/api" -import { Button } from "@/components/ui/button" -import { ArrowLeft, MapPin, Package, FolderKanban } from "lucide-react" +import { type SessionContext, saveSession } from "@/lib/api" -export type DetectionType = "pothole-detection" | "sign-board-detection" +export default function SelectionPage() { + const router = useRouter() -export type DetectionData = { - video_id: string - detection_type?: string - output_video_path?: string - video_info: { - fps: number - width: number - height: number - total_frames: number - } - summary: { - unique_potholes?: number - unique_signboards?: number - total_detections: number - total_frames: number - detection_rate: number - } - pothole_list?: Array<{ - pothole_id: number - first_detected_frame: number - first_detected_time: number - confidence: number - lat?: number - lng?: number - }> - signboard_list?: Array<{ - signboard_id: number - type: string - first_detected_frame: number - first_detected_time: number - confidence: number - lat?: number - lng?: number - }> - frames: Array<{ - frame_id: number - potholes?: Array<{ - pothole_id: number - bbox: { - x1: number - y1: number - x2: number - y2: number - } - confidence: number - }> - signboards?: Array<{ - signboard_id: number - type: string - bbox: { - x1: number - y1: number - x2: number - y2: number - } - confidence: number - }> - }> -} - -export default function DetectionPage() { - const [session, setSession] = useState(emptySessionContext) - const [detectionData, setDetectionData] = useState(null) - const [videoId, setVideoId] = useState(null) - const [videoFile, setVideoFile] = useState(null) - const [detectionType, setDetectionType] = useState("pothole-detection") - - const isSessionComplete = session.projectId && session.packageId && session.locationId - - const getTitle = () => { - return detectionType === "pothole-detection" - ? "Pothole Detection System" - : "Signboard Detection System" - } - - const getDescription = () => { - return detectionType === "pothole-detection" - ? "Upload a video to detect and track potholes with AI-powered analysis" - : "Upload a video to detect and identify signboards with AI-powered analysis" - } - - const handleSelectionComplete = (newSession: SessionContext) => { - setSession(newSession) - } - - const handleBackToSelection = () => { - setSession(emptySessionContext) - setDetectionData(null) - setVideoId(null) - setVideoFile(null) + const handleSelectionComplete = (session: SessionContext) => { + // Save session to storage and navigate to upload page + saveSession(session) + router.push("/upload") } return ( @@ -108,74 +19,17 @@ export default function DetectionPage() { {/* Header */}

- {isSessionComplete ? getTitle() : "VisionRoad Detection System"} + VisionRoad Detection System

- {isSessionComplete ? getDescription() : "Select your project location to begin AI-powered road analysis"} + Select your project location to begin AI-powered road analysis

- {/* Session Info Bar */} - {isSessionComplete && ( -
-
-
-
- - Project: - {session.projectName} -
-
- - Package: - {session.packageName} -
-
- - Location: - {session.locationName} -
-
- -
-
- )} - - {/* Project Selection Section - Show when session is not complete */} - {!isSessionComplete && ( -
- -
- )} - - {/* Upload Section - Show after session is complete */} - {isSessionComplete && ( -
- { - setDetectionData(data) - setVideoId(vId) - setVideoFile(file) - }} - onDetectionTypeChange={setDetectionType} - /> -
- )} - - {/* Video Player Section */} - {detectionData && videoId && videoFile && ( -
- -
- )} + {/* Project Selection Section */} +
+ +
) diff --git a/app/results/page.tsx b/app/results/page.tsx new file mode 100644 index 0000000..0599d4d --- /dev/null +++ b/app/results/page.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Loader2, ArrowLeft, MapPin, Package, FolderKanban, RotateCcw } from "lucide-react" +import VideoPlayerSection from "@/components/video-player-section" +import { + type SessionContext, + loadSession, + loadVideoData, + isSessionValid, + clearSession +} from "@/lib/api" +import { getVideoFile, clearVideoFile } from "@/lib/video-storage" + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000/api/v1" + +type DetectionType = "pothole-detection" | "sign-board-detection" + +type DetectionData = { + video_id: string + detection_type?: string + output_video_path?: string + video_info: { + fps: number + width: number + height: number + total_frames: number + } + summary: { + unique_potholes?: number + unique_signboards?: number + total_detections: number + total_frames: number + detection_rate: number + } + pothole_list?: Array<{ + pothole_id: number + first_detected_frame: number + first_detected_time: number + confidence: number + lat?: number + lng?: number + }> + signboard_list?: Array<{ + signboard_id: number + type: string + first_detected_frame: number + first_detected_time: number + confidence: number + lat?: number + lng?: number + }> + frames: Array<{ + frame_id: number + potholes?: Array<{ + pothole_id: number + bbox: { + x1: number + y1: number + x2: number + y2: number + } + confidence: number + }> + signboards?: Array<{ + signboard_id: number + type: string + bbox: { + x1: number + y1: number + x2: number + y2: number + } + confidence: number + }> + }> +} + +export default function ResultsPage() { + const router = useRouter() + const [session, setSession] = useState(null) + const [detectionData, setDetectionData] = useState(null) + const [detectionType, setDetectionType] = useState("pothole-detection") + const [videoId, setVideoId] = useState(null) + const [videoFile, setVideoFile] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Load session and video data on mount + useEffect(() => { + const storedSession = loadSession() + const videoData = loadVideoData() + + if (!isSessionValid(storedSession) || !videoData) { + router.replace("/") + return + } + + setSession(storedSession) + setVideoId(videoData.videoId) + setDetectionType(videoData.detectionType as DetectionType) + + // Fetch detection results and video file + const fetchResults = async () => { + try { + // Fetch detection data + const response = await fetch(`${API_URL}/results/${videoData.videoId}`, { + headers: { "ngrok-skip-browser-warning": "true" } + }) + + if (!response.ok) { + throw new Error(`Failed to load results: ${response.status}`) + } + + const data = await response.json() + setDetectionData(data) + + // Retrieve video file from IndexedDB + const storedVideoFile = await getVideoFile(videoData.videoId) + if (storedVideoFile) { + setVideoFile(storedVideoFile) + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load results") + } finally { + setIsLoading(false) + } + } + + fetchResults() + }, [router]) + + const handleNewAnalysis = async () => { + // Clear video from IndexedDB + if (videoId) { + try { + await clearVideoFile(videoId) + } catch (err) { + console.error("Failed to clear video file:", err) + } + } + clearSession() + router.push("/") + } + + const handleBackToUpload = () => { + router.push("/upload") + } + + const getTitle = () => { + return detectionType === "pothole-detection" + ? "Pothole Detection Results" + : "Signboard Detection Results" + } + + if (isLoading) { + return ( +
+ +

Loading detection results...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+
+ {/* Header */} +
+

+ {getTitle()} +

+

+ View your AI-powered road analysis results +

+
+ + {/* Session Info Bar */} + {session && ( +
+
+
+
+ + Project: + {session.projectName} +
+
+ + Package: + {session.packageName} +
+
+ + Location: + {session.locationName} +
+
+
+ + +
+
+
+ )} + + {/* Video Player Section */} + {detectionData && videoId && ( +
+ +
+ )} +
+
+ ) +} diff --git a/app/upload/page.tsx b/app/upload/page.tsx new file mode 100644 index 0000000..a411602 --- /dev/null +++ b/app/upload/page.tsx @@ -0,0 +1,337 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Upload, Loader2, AlertCircle, ArrowLeft, MapPin, Package, FolderKanban } from "lucide-react" +import { + type SessionContext, + loadSession, + isSessionValid, + saveVideoData, + clearSession +} from "@/lib/api" +import { storeVideoFile } from "@/lib/video-storage" + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000/api/v1" +const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://127.0.0.1:8000/api/v1" + +type DetectionType = "pothole-detection" | "sign-board-detection" + +export default function UploadPage() { + const router = useRouter() + const [session, setSession] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Form states + const [file, setFile] = useState(null) + const [jsonFile, setJsonFile] = useState(null) + const [speed, setSpeed] = useState(30) + const [detectionType, setDetectionType] = useState("pothole-detection") + + // Upload states + const [uploading, setUploading] = useState(false) + const [progress, setProgress] = useState(0) + const [statusMessage, setStatusMessage] = useState("") + const [error, setError] = useState(null) + + // Load session on mount + useEffect(() => { + const storedSession = loadSession() + if (!isSessionValid(storedSession)) { + router.replace("/") + return + } + setSession(storedSession) + setIsLoading(false) + }, [router]) + + const connectWebSocket = (videoId: string) => { + const ws = new WebSocket(`${WS_URL}/ws/${videoId}`) + + ws.onmessage = async (event) => { + const data = JSON.parse(event.data) + + if (data.type === "progress" || data.progress !== undefined) { + setProgress(data.progress || 0) + let message = data.message || "Processing..." + if (data.unique_potholes !== undefined) { + message += ` | Unique: ${data.unique_potholes} | Total: ${data.total_detections || 0}` + } else if (data.unique_signboards !== undefined) { + message += ` | Unique: ${data.unique_signboards} | Total: ${data.total_detections || 0}` + } + setStatusMessage(message) + } + + if (data.type === "complete" || data.status === "completed") { + setStatusMessage("Processing completed! Saving video...") + ws.close() + // Store video file in IndexedDB for results page + if (file) { + try { + await storeVideoFile(videoId, file) + } catch (err) { + console.error("Failed to store video file:", err) + } + } + // Save video data and navigate to results + saveVideoData({ videoId, detectionType }) + setStatusMessage("Redirecting to results...") + setTimeout(() => router.push("/results"), 500) + } + + if (data.type === "error") { + setError("Error: " + data.message) + setStatusMessage("") + setUploading(false) + ws.close() + } + } + + ws.onerror = () => { + setStatusMessage("Connection error. Retrying...") + } + } + + const handleUpload = async () => { + if (!file) { + setError("Please select a video file") + return + } + + const formData = new FormData() + formData.append("file", file) + formData.append("detection_type", detectionType) + formData.append("speed_kmh", speed.toString()) + if (jsonFile) { + formData.append("json_file", jsonFile) + } + + setUploading(true) + setProgress(0) + setStatusMessage("Uploading...") + setError(null) + + try { + const response = await fetch(`${API_URL}/upload`, { + method: "POST", + headers: { "ngrok-skip-browser-warning": "true" }, + body: formData + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Upload failed (${response.status}): ${errorText}`) + } + + const result = await response.json() + setStatusMessage("Uploaded! Starting processing...") + setProgress(10) + connectWebSocket(result.video_id) + } catch (err) { + let errorMessage = "Upload failed" + if (err instanceof TypeError && err.message === "Failed to fetch") { + errorMessage = "Cannot connect to server. Please check if backend is running." + } else if (err instanceof Error) { + errorMessage = err.message + } + setError(errorMessage) + setStatusMessage("") + setUploading(false) + setProgress(0) + } + } + + const handleBackToSelection = () => { + clearSession() + router.push("/") + } + + const getTitle = () => { + return detectionType === "pothole-detection" + ? "Pothole Detection System" + : "Signboard Detection System" + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {/* Header */} +
+

+ {getTitle()} +

+

+ Upload a video to detect and analyze with AI-powered processing +

+
+ + {/* Session Info Bar */} + {session && ( +
+
+
+
+ + Project: + {session.projectName} +
+
+ + Package: + {session.packageName} +
+
+ + Location: + {session.locationName} +
+
+ +
+
+ )} + + {/* Upload Card */} + + + Upload Video + + Select a video file, detection type, and vehicle speed to start AI-powered analysis + + + +
+ {/* Video File Input */} +
+ + { + setFile(e.target.files?.[0] || null) + setError(null) + }} + disabled={uploading} + /> + {file && ( +

+ Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) +

+ )} +
+ + {/* JSON File Input */} +
+ + { + setJsonFile(e.target.files?.[0] || null) + setError(null) + }} + disabled={uploading} + /> + {jsonFile && ( +

+ Selected: {jsonFile.name} ({(jsonFile.size / 1024).toFixed(2)} KB) +

+ )} +
+ + {/* Detection Type */} +
+ + +
+ + {/* Speed Input */} +
+ + setSpeed(Number(e.target.value))} + disabled={uploading} + /> +
+
+ + {/* Error Display */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Upload Button */} + + + {/* Progress Section */} + {uploading && ( +
+ +
+ {statusMessage} + {progress}% +
+
+ )} +
+
+
+
+ ) +} diff --git a/components/video-player-section.tsx b/components/video-player-section.tsx index d88aa3b..8c385bf 100644 --- a/components/video-player-section.tsx +++ b/components/video-player-section.tsx @@ -5,14 +5,75 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" import { Target, AlertTriangle, Film, Activity, Gauge, Monitor, SignpostBig } from "lucide-react" -import type { DetectionData, DetectionType } from "@/app/page" const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000/api/v1" +type DetectionType = "pothole-detection" | "sign-board-detection" + +type DetectionData = { + video_id: string + detection_type?: string + output_video_path?: string + video_info: { + fps: number + width: number + height: number + total_frames: number + } + summary: { + unique_potholes?: number + unique_signboards?: number + total_detections: number + total_frames: number + detection_rate: number + } + pothole_list?: Array<{ + pothole_id: number + first_detected_frame: number + first_detected_time: number + confidence: number + lat?: number + lng?: number + }> + signboard_list?: Array<{ + signboard_id: number + type: string + first_detected_frame: number + first_detected_time: number + confidence: number + lat?: number + lng?: number + }> + frames: Array<{ + frame_id: number + potholes?: Array<{ + pothole_id: number + bbox: { + x1: number + y1: number + x2: number + y2: number + } + confidence: number + }> + signboards?: Array<{ + signboard_id: number + type: string + bbox: { + x1: number + y1: number + x2: number + y2: number + } + confidence: number + }> + }> +} + type VideoPlayerSectionProps = { data: DetectionData videoId: string - videoFile: File + videoFile: File | null // null when loading from server (results page) detectionType: DetectionType } @@ -138,7 +199,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection // Build GPS map from signboard_list or pothole_list useEffect(() => { const map = new Map() - + if (isPothole && data.pothole_list) { data.pothole_list.forEach(item => { if (item.lat !== undefined && item.lng !== undefined) { @@ -152,7 +213,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection } }) } - + gpsMap.current = map console.log(`[VideoPlayer] GPS map built with ${map.size} entries`) }, [data, isPothole, isSignboard]) @@ -163,7 +224,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const secs = Math.floor(seconds % 60) - + if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } @@ -174,13 +235,13 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const seekToFrame = useCallback((frame: number) => { const video = videoRef.current if (!video) return - + // Calculate time from frame number const time = frame / data.video_info.fps - + // Set video currentTime (this will trigger seeked event) video.currentTime = time - + console.log(`[SeekToFrame] Jumping to frame ${frame} at ${time.toFixed(2)}s`) }, [data.video_info.fps]) @@ -192,7 +253,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection console.log(`[VideoPlayer] Building frame map from ${data.frames.length} frames`) data.frames.forEach((frameData) => { const frameId = frameData.frame_id - + // Handle both pothole and signboard detections const detections = isPothole ? (frameData.potholes || []) : (frameData.signboards || []) @@ -210,7 +271,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const drawBoundingBoxes = useCallback((detections: any[]) => { const canvas = canvasRef.current const video = videoRef.current - + if (!canvas || !video) return const ctx = canvas.getContext('2d') @@ -240,7 +301,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection // Set colors based on detection type const boxColor = isPothole ? '#ef4444' : '#3b82f6' // red for potholes, blue for signboards const textBgColor = isPothole ? 'rgba(239, 68, 68, 0.9)' : 'rgba(59, 130, 246, 0.9)' - + // Draw bounding box ctx.strokeStyle = boxColor ctx.lineWidth = 3 @@ -253,8 +314,8 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection // Prepare label text const id = isPothole ? detection.pothole_id : detection.signboard_id const confidence = (detection.confidence * 100).toFixed(1) - let labelText = isPothole - ? `Pothole #${id}` + let labelText = isPothole + ? `Pothole #${id}` : `${detection.type || 'Sign'} #${id}` labelText += ` ${confidence}%` @@ -278,7 +339,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const video = videoRef.current const canvas = canvasRef.current const container = containerRef.current - + if (!video || !canvas || !container) return // Get the displayed size of the video @@ -313,64 +374,76 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection return () => video.removeEventListener('loadedmetadata', handleLoadedMetadata) }, [resizeCanvas]) - // Load video from uploaded file + // Load video from uploaded file or from server useEffect(() => { - if (videoRef.current && videoFile) { - const videoUrl = URL.createObjectURL(videoFile) + const video = videoRef.current + if (!video) return + + let videoUrl: string + + if (videoFile) { + // Load from local file (upload page) + videoUrl = URL.createObjectURL(videoFile) console.log(`[VideoPlayer] Loading video from uploaded file`) - - videoRef.current.src = videoUrl - - // Handle video load errors - const handleError = () => { - console.error("[VideoPlayer] Failed to load video") - setVideoError("Failed to load video. Please try refreshing the page.") - } - - const handleLoaded = () => { - console.log("[VideoPlayer] Video loaded successfully") - setVideoError(null) - resizeCanvas() - } + } else if (videoId) { + // Load from server (results page) + videoUrl = `${API_URL}/video/${videoId}` + console.log(`[VideoPlayer] Loading video from server: ${videoUrl}`) + } else { + return + } - videoRef.current.addEventListener("error", handleError) - videoRef.current.addEventListener("loadeddata", handleLoaded) + video.src = videoUrl - return () => { - if (videoRef.current) { - videoRef.current.removeEventListener("error", handleError) - videoRef.current.removeEventListener("loadeddata", handleLoaded) - } - // Revoke object URL to free memory + // Handle video load errors + const handleError = () => { + console.error("[VideoPlayer] Failed to load video") + setVideoError("Failed to load video. Please try refreshing the page.") + } + + const handleLoaded = () => { + console.log("[VideoPlayer] Video loaded successfully") + setVideoError(null) + resizeCanvas() + } + + video.addEventListener("error", handleError) + video.addEventListener("loadeddata", handleLoaded) + + return () => { + video.removeEventListener("error", handleError) + video.removeEventListener("loadeddata", handleLoaded) + // Revoke object URL only if it was created from a file + if (videoFile) { URL.revokeObjectURL(videoUrl) } } - }, [videoFile, resizeCanvas]) + }, [videoFile, videoId, resizeCanvas]) // Add detection log with deduplication and size limit const addDetectionLog = useCallback((frame: number, detections: any[]) => { if (loggedFrames.current.has(frame)) return - + loggedFrames.current.add(frame) - + // Update last detected GPS coordinates if available if (detections.length > 0) { const detectionId = isPothole ? detections[0].pothole_id : detections[0].signboard_id const gpsCoords = gpsMap.current.get(detectionId) - + if (gpsCoords) { setLastDetectedLat(gpsCoords.lat) setLastDetectedLng(gpsCoords.lng) } } - + setLogs((prev) => { const newLog: DetectionLog = { frame, detections: detections.map((det) => { const detectionId = isPothole ? det.pothole_id : det.signboard_id const gpsCoords = gpsMap.current.get(detectionId) - + return { id: detectionId, type: isSignboard ? det.type : undefined, @@ -382,7 +455,7 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection }), videoTime: formatVideoTime(frame, data.video_info.fps), } - + const updated = [newLog, ...prev].slice(0, MAX_LOGS) return updated }) @@ -394,17 +467,17 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection if (!video) return const frame = Math.round(video.currentTime * data.video_info.fps) - + if (!video.paused && !video.ended && frame !== lastProcessedFrame.current) { lastProcessedFrame.current = frame setCurrentFrame(frame) - + const detections = frameDetectionMap.current.get(frame) setDetectionsCount(detections?.length || 0) - + // Draw bounding boxes for current frame drawBoundingBoxes(detections || []) - + if (detections && detections.length > 0) { addDetectionLog(frame, detections) } @@ -457,10 +530,10 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const handleTimeUpdate = () => { const frame = Math.round(video.currentTime * data.video_info.fps) setCurrentFrame(frame) - + const detections = frameDetectionMap.current.get(frame) setDetectionsCount(detections?.length || 0) - + // Draw bounding boxes for current frame drawBoundingBoxes(detections || []) } @@ -468,29 +541,29 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection const handleSeeked = () => { // Clear logged frames to allow re-logging if seeking back loggedFrames.current.clear() - + // Immediately update frame info const frame = Math.round(video.currentTime * data.video_info.fps) setCurrentFrame(frame) - + const detections = frameDetectionMap.current.get(frame) setDetectionsCount(detections?.length || 0) - + // Update GPS coordinates immediately on seek if (detections && detections.length > 0) { const detectionId = isPothole ? detections[0].pothole_id : detections[0].signboard_id const gpsCoords = gpsMap.current.get(detectionId) - + if (gpsCoords) { setLastDetectedLat(gpsCoords.lat) setLastDetectedLng(gpsCoords.lng) console.log(`[Seek] Updated GPS: ${gpsCoords.lat}, ${gpsCoords.lng} at frame ${frame}`) } - + // Add detection log for the seeked frame addDetectionLog(frame, detections) } - + // Draw bounding boxes for seeked frame drawBoundingBoxes(detections || []) } @@ -532,8 +605,8 @@ export default function VideoPlayerSection({ data, videoId, videoFile, detection {videoError} )} - -
Your browser does not support the video tag. - + {/* Canvas overlay for bounding boxes */} seekToFrame(log.frame)} - className={`text-xs p-3 bg-card rounded-md border-l-2 cursor-pointer hover:bg-accent/50 transition-colors ${ - isPothole ? "border-red-500" : "border-blue-500" - }`} + className={`text-xs p-3 bg-card rounded-md border-l-2 cursor-pointer hover:bg-accent/50 transition-colors ${isPothole ? "border-red-500" : "border-blue-500" + }`} >
Frame: {log.frame} diff --git a/lib/api.ts b/lib/api.ts index bf8e575..126b9f3 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -101,3 +101,78 @@ export const emptySessionContext: SessionContext = { locationId: null, locationName: null } + +// Session Storage Keys +const SESSION_KEY = "visionroad_session" +const VIDEO_DATA_KEY = "visionroad_video_data" +const DETECTION_TYPE_KEY = "visionroad_detection_type" + +/** + * Save session to sessionStorage + */ +export function saveSession(session: SessionContext): void { + if (typeof window !== "undefined") { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) + } +} + +/** + * Load session from sessionStorage + */ +export function loadSession(): SessionContext { + if (typeof window !== "undefined") { + const stored = sessionStorage.getItem(SESSION_KEY) + if (stored) { + return JSON.parse(stored) + } + } + return emptySessionContext +} + +/** + * Clear all session data + */ +export function clearSession(): void { + if (typeof window !== "undefined") { + sessionStorage.removeItem(SESSION_KEY) + sessionStorage.removeItem(VIDEO_DATA_KEY) + sessionStorage.removeItem(DETECTION_TYPE_KEY) + } +} + +/** + * Video data for results page + */ +export interface VideoResultData { + videoId: string + detectionType: string +} + +/** + * Save video result data + */ +export function saveVideoData(data: VideoResultData): void { + if (typeof window !== "undefined") { + sessionStorage.setItem(VIDEO_DATA_KEY, JSON.stringify(data)) + } +} + +/** + * Load video result data + */ +export function loadVideoData(): VideoResultData | null { + if (typeof window !== "undefined") { + const stored = sessionStorage.getItem(VIDEO_DATA_KEY) + if (stored) { + return JSON.parse(stored) + } + } + return null +} + +/** + * Check if session is complete + */ +export function isSessionValid(session: SessionContext): boolean { + return !!(session.projectId && session.packageId && session.locationId) +} diff --git a/lib/video-storage.ts b/lib/video-storage.ts new file mode 100644 index 0000000..72feda3 --- /dev/null +++ b/lib/video-storage.ts @@ -0,0 +1,109 @@ +/** + * IndexedDB storage for video files + * Used to persist video files across page navigations + */ + +const DB_NAME = 'visionroad_db' +const DB_VERSION = 1 +const VIDEO_STORE = 'videos' + +let db: IDBDatabase | null = null + +/** + * Open the IndexedDB database + */ +async function openDB(): Promise { + if (db) return db + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + db = request.result + resolve(db) + } + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result + if (!database.objectStoreNames.contains(VIDEO_STORE)) { + database.createObjectStore(VIDEO_STORE, { keyPath: 'id' }) + } + } + }) +} + +/** + * Store a video file in IndexedDB + */ +export async function storeVideoFile(videoId: string, file: File): Promise { + const database = await openDB() + + return new Promise((resolve, reject) => { + const transaction = database.transaction([VIDEO_STORE], 'readwrite') + const store = transaction.objectStore(VIDEO_STORE) + + const request = store.put({ + id: videoId, + file: file, + timestamp: Date.now() + }) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +} + +/** + * Retrieve a video file from IndexedDB + */ +export async function getVideoFile(videoId: string): Promise { + const database = await openDB() + + return new Promise((resolve, reject) => { + const transaction = database.transaction([VIDEO_STORE], 'readonly') + const store = transaction.objectStore(VIDEO_STORE) + + const request = store.get(videoId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const result = request.result + resolve(result ? result.file : null) + } + }) +} + +/** + * Clear a video file from IndexedDB + */ +export async function clearVideoFile(videoId: string): Promise { + const database = await openDB() + + return new Promise((resolve, reject) => { + const transaction = database.transaction([VIDEO_STORE], 'readwrite') + const store = transaction.objectStore(VIDEO_STORE) + + const request = store.delete(videoId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +} + +/** + * Clear all video files from IndexedDB + */ +export async function clearAllVideos(): Promise { + const database = await openDB() + + return new Promise((resolve, reject) => { + const transaction = database.transaction([VIDEO_STORE], 'readwrite') + const store = transaction.objectStore(VIDEO_STORE) + + const request = store.clear() + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +}