Spaces:
Running
Running
import React, { useState, useRef, useEffect } from "react"; | |
import { useVLMContext } from "../context/useVLMContext"; | |
import { extractJsonFromMarkdown, drawBoundingBoxesOnCanvas } from "./BoxAnnotator"; | |
const MODES = ["Webcam", "URL", "File"] as const; | |
type Mode = typeof MODES[number]; | |
const EXAMPLE_VIDEO_URL = "/space/videos/1.mp4"; | |
const EXAMPLE_PROMPT = "Detect all people in the image. For each person, output a JSON array of objects with fields: 'label' (string) and 'bbox_2d' ([x1, y1, x2, y2]) where coordinates are in pixel values. Example: [{\"label\": \"person\", \"bbox_2d\": [100, 50, 200, 300]}]"; | |
function parseFlatBoxArray(arr: any[]): { label: string, bbox_2d: number[] }[] { | |
if (typeof arr[0] === "string" && Array.isArray(arr[1])) { | |
const label = arr[0]; | |
return arr.slice(1).map(bbox => ({ label, bbox_2d: bbox })); | |
} | |
return []; | |
} | |
function normalizeBoxes(raw: any): { label: string, bbox_2d: number[] }[] { | |
if (!raw) return []; | |
let boxes = []; | |
if (typeof raw === "object" && raw !== null && Array.isArray(raw.image)) { | |
boxes = raw.image; | |
} else if (Array.isArray(raw)) { | |
boxes = raw; | |
} else if (typeof raw === "object" && raw !== null) { | |
boxes = [raw]; | |
} | |
return boxes | |
.map((obj: any) => { | |
if (!obj || !obj.bbox_2d) return null; | |
let bbox = obj.bbox_2d; | |
// If bbox_2d is [[x1, y1], [x2, y2]], convert to [x1, y1, x2, y2] | |
if ( | |
Array.isArray(bbox) && | |
bbox.length === 2 && | |
Array.isArray(bbox[0]) && | |
Array.isArray(bbox[1]) && | |
bbox[0].length === 2 && | |
bbox[1].length === 2 | |
) { | |
bbox = [bbox[0][0], bbox[0][1], bbox[1][0], bbox[1][1]]; | |
} | |
// If bbox_2d is [x1, y1, x2, y2], use as-is | |
if ( | |
Array.isArray(bbox) && | |
bbox.length === 4 && | |
bbox.every((v: any) => typeof v === "number") | |
) { | |
return { ...obj, bbox_2d: bbox }; | |
} | |
// Otherwise, skip | |
return null; | |
}) | |
.filter((obj: any) => obj); | |
} | |
function isImageFile(file: File) { | |
return file.type.startsWith("image/"); | |
} | |
function isVideoFile(file: File) { | |
return file.type.startsWith("video/"); | |
} | |
export default function MultiSourceCaptioningView() { | |
const [mode, setMode] = useState<Mode>("File"); | |
const [videoUrl, setVideoUrl] = useState<string>(EXAMPLE_VIDEO_URL); | |
const [inputUrl, setInputUrl] = useState<string>(EXAMPLE_VIDEO_URL); | |
const [prompt, setPrompt] = useState<string>(EXAMPLE_PROMPT); | |
const [processing, setProcessing] = useState(false); | |
const [error, setError] = useState<string | null>(null); | |
const [webcamActive, setWebcamActive] = useState(false); | |
const [uploadedFile, setUploadedFile] = useState<File | null>(null); | |
const [uploadedUrl, setUploadedUrl] = useState<string>(""); | |
const [videoProcessing, setVideoProcessing] = useState(false); | |
const [imageProcessed, setImageProcessed] = useState(false); | |
const [exampleProcessing, setExampleProcessing] = useState(false); | |
const [urlProcessing, setUrlProcessing] = useState(false); | |
const [debugOutput, setDebugOutput] = useState<string>(""); | |
const [canvasDims, setCanvasDims] = useState<{w:number,h:number}|null>(null); | |
const [videoDims, setVideoDims] = useState<{w:number,h:number}|null>(null); | |
const [inferenceStatus, setInferenceStatus] = useState<string>(""); | |
const videoRef = useRef<HTMLVideoElement | null>(null); | |
const overlayVideoRef = useRef<HTMLVideoElement | null>(null); // NEW: overlay video | |
const canvasRef = useRef<HTMLCanvasElement | null>(null); | |
const imageRef = useRef<HTMLImageElement | null>(null); | |
const webcamStreamRef = useRef<MediaStream | null>(null); | |
const { isLoaded, isLoading, error: modelError, runInference } = useVLMContext(); | |
// Persistent boxes state: {boxes: [...], timestamp: number} | |
const [persistentBoxes, setPersistentBoxes] = useState<{boxes: {label: string, bbox_2d: number[]}[], timestamp: number}[]>([]); | |
const BOX_PERSIST_MS = 2000; // 2 seconds | |
// Helper: Add new boxes with timestamp | |
const addBoxesWithTimestamp = (boxes: {label: string, bbox_2d: number[]}[]) => { | |
if (!boxes || boxes.length === 0) return; | |
setPersistentBoxes((prev: {boxes: {label: string, bbox_2d: number[]}[], timestamp: number}[]) => [ | |
...prev.filter((entry: {boxes: {label: string, bbox_2d: number[]}[], timestamp: number}) => Date.now() - entry.timestamp < BOX_PERSIST_MS), | |
{ boxes, timestamp: Date.now() } | |
]); | |
}; | |
// Helper: Get all boxes from last 2 seconds | |
const getCurrentBoxes = () => { | |
const now = Date.now(); | |
return persistentBoxes | |
.filter((entry: {boxes: {label: string, bbox_2d: number[]}[], timestamp: number}) => now - entry.timestamp < BOX_PERSIST_MS) | |
.flatMap((entry: {boxes: {label: string, bbox_2d: number[]}[], timestamp: number}) => entry.boxes); | |
}; | |
// Synchronize overlay video with main video | |
useEffect(() => { | |
const main = videoRef.current; | |
const overlay = overlayVideoRef.current; | |
if (!main || !overlay) return; | |
// Sync play/pause | |
const syncPlay = () => { if (main.paused !== overlay.paused) main.paused ? overlay.pause() : overlay.play(); }; | |
main.addEventListener('play', () => overlay.play()); | |
main.addEventListener('pause', () => overlay.pause()); | |
// Sync seeking | |
const syncTime = () => { if (Math.abs(main.currentTime - overlay.currentTime) > 0.05) overlay.currentTime = main.currentTime; }; | |
main.addEventListener('seeked', syncTime); | |
main.addEventListener('timeupdate', syncTime); | |
// Clean up | |
return () => { | |
main.removeEventListener('play', () => overlay.play()); | |
main.removeEventListener('pause', () => overlay.pause()); | |
main.removeEventListener('seeked', syncTime); | |
main.removeEventListener('timeupdate', syncTime); | |
}; | |
}, [videoRef, overlayVideoRef, uploadedUrl, videoUrl, mode]); | |
// Update: processVideoFrame now adds boxes to persistentBoxes | |
const processVideoFrame = async () => { | |
if (!videoRef.current || !canvasRef.current) return; | |
const video = videoRef.current; | |
const canvas = canvasRef.current; | |
if (video.paused || video.ended || video.videoWidth === 0) return; | |
canvas.width = video.videoWidth; | |
canvas.height = video.videoHeight; | |
const ctx = canvas.getContext("2d"); | |
if (!ctx) return; | |
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
await runInference(video, prompt, (output: string) => { | |
setDebugOutput(output); | |
let boxes = extractJsonFromMarkdown(output) || []; | |
if (boxes.length === 0 && Array.isArray(output)) { | |
boxes = parseFlatBoxArray(output); | |
} | |
boxes = normalizeBoxes(boxes); | |
if (boxes.length === 0) setInferenceStatus("No boxes detected or model output invalid."); | |
if (Array.isArray(boxes) && boxes.length > 0) { | |
addBoxesWithTimestamp(boxes); // <-- Add to persistent state | |
} | |
}); | |
}; | |
// Draw persistent boxes on every frame | |
useEffect(() => { | |
const draw = () => { | |
if (!videoRef.current || !canvasRef.current) return; | |
const video = videoRef.current; | |
const canvas = canvasRef.current; | |
if (video.videoWidth === 0) return; | |
canvas.width = video.videoWidth; | |
canvas.height = video.videoHeight; | |
const ctx = canvas.getContext("2d"); | |
if (!ctx) return; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
const boxes = getCurrentBoxes(); | |
if (boxes.length > 0) { | |
const scaleX = canvas.width / video.videoWidth; | |
const scaleY = canvas.height / video.videoHeight; | |
drawBoundingBoxesOnCanvas(ctx, boxes, { color: "#FF00FF", lineWidth: 4, font: "20px Arial", scaleX, scaleY }); | |
} | |
}; | |
draw(); | |
const interval = setInterval(draw, 100); | |
return () => clearInterval(interval); | |
}, [persistentBoxes, videoRef, canvasRef]); | |
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const file = e.target.files?.[0] || null; | |
setUploadedFile(file); | |
setUploadedUrl(file ? URL.createObjectURL(file) : ""); | |
setError(null); | |
setImageProcessed(false); | |
setVideoProcessing(false); | |
setExampleProcessing(false); | |
}; | |
// Webcam setup and teardown (unchanged) | |
useEffect(() => { | |
if (mode !== "Webcam") { | |
if (webcamStreamRef.current) { | |
webcamStreamRef.current.getTracks().forEach((track: MediaStreamTrack) => track.stop()); | |
webcamStreamRef.current = null; | |
} | |
setWebcamActive(false); | |
return; | |
} | |
const setupWebcam = async () => { | |
try { | |
setError(null); | |
const stream = await navigator.mediaDevices.getUserMedia({ video: true }); | |
webcamStreamRef.current = stream; | |
if (videoRef.current) { | |
videoRef.current.srcObject = stream; | |
setWebcamActive(true); | |
} | |
} catch (e) { | |
setError("Could not access webcam: " + (e instanceof Error ? e.message : String(e))); | |
setWebcamActive(false); | |
} | |
}; | |
setupWebcam(); | |
return () => { | |
if (webcamStreamRef.current) { | |
webcamStreamRef.current.getTracks().forEach((track: MediaStreamTrack) => track.stop()); | |
webcamStreamRef.current = null; | |
} | |
setWebcamActive(false); | |
}; | |
}, [mode]); | |
// Webcam mode: process frames with setInterval | |
useEffect(() => { | |
if (mode !== "Webcam" || !isLoaded || !webcamActive) return; | |
let interval: ReturnType<typeof setInterval> | null = null; | |
interval = setInterval(() => { | |
processVideoFrame(); | |
}, 1000); | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [mode, isLoaded, prompt, runInference, webcamActive]); | |
// URL mode: process frames with setInterval | |
useEffect(() => { | |
if (mode !== "URL" || !isLoaded || !urlProcessing) return; | |
let interval: ReturnType<typeof setInterval> | null = null; | |
interval = setInterval(() => { | |
processVideoFrame(); | |
}, 1000); | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [mode, isLoaded, prompt, runInference, urlProcessing]); | |
// File video mode: process frames with setInterval | |
useEffect(() => { | |
if (mode !== "File" || !isLoaded || !uploadedFile || !isVideoFile(uploadedFile) || !videoProcessing) return; | |
let interval: ReturnType<typeof setInterval> | null = null; | |
interval = setInterval(() => { | |
processVideoFrame(); | |
}, 1000); | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [mode, isLoaded, prompt, runInference, uploadedFile, videoProcessing]); | |
// Example video mode: process frames with setInterval | |
useEffect(() => { | |
if (mode !== "File" || uploadedFile || !isLoaded || !exampleProcessing) return; | |
let interval: ReturnType<typeof setInterval> | null = null; | |
interval = setInterval(() => { | |
processVideoFrame(); | |
}, 1000); | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [mode, isLoaded, prompt, runInference, uploadedFile, exampleProcessing]); | |
// File mode: process uploaded image (only on button click) | |
const handleProcessImage = async () => { | |
if (!isLoaded || !uploadedFile || !isImageFile(uploadedFile) || !imageRef.current || !canvasRef.current) return; | |
const img = imageRef.current; | |
const canvas = canvasRef.current; | |
canvas.width = img.naturalWidth; | |
canvas.height = img.naturalHeight; | |
setCanvasDims({w:canvas.width,h:canvas.height}); | |
setVideoDims({w:img.naturalWidth,h:img.naturalHeight}); | |
const ctx = canvas.getContext("2d"); | |
if (!ctx) return; | |
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
setProcessing(true); | |
setError(null); | |
setInferenceStatus("Running inference..."); | |
await runInference(img, prompt, (output: string) => { | |
setDebugOutput(output); | |
setInferenceStatus("Inference complete."); | |
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
let boxes = extractJsonFromMarkdown(output) || []; | |
if (boxes.length === 0 && Array.isArray(output)) { | |
boxes = parseFlatBoxArray(output); | |
} | |
boxes = normalizeBoxes(boxes); | |
console.log("Model output:", output); | |
console.log("Boxes after normalization:", boxes); | |
console.log("Canvas size:", canvas.width, canvas.height); | |
if (boxes.length > 0) { | |
const [x1, y1, x2, y2] = boxes[0].bbox_2d; | |
console.log("First box coords:", x1, y1, x2, y2); | |
} | |
if (boxes.length === 0) setInferenceStatus("No boxes detected or model output invalid."); | |
if (Array.isArray(boxes) && boxes.length > 0) { | |
const scaleX = canvas.width / img.naturalWidth; | |
const scaleY = canvas.height / img.naturalHeight; | |
drawBoundingBoxesOnCanvas(ctx, boxes, { scaleX, scaleY }); | |
} | |
setImageProcessed(true); | |
}); | |
setProcessing(false); | |
}; | |
// File mode: process uploaded video frames (start/stop) | |
const handleToggleVideoProcessing = () => { | |
setVideoProcessing((prev) => !prev); | |
}; | |
// Handle start/stop for example video processing | |
const handleToggleExampleProcessing = () => { | |
setExampleProcessing((prev) => !prev); | |
}; | |
// Handle start/stop for URL video processing | |
const handleToggleUrlProcessing = () => { | |
setUrlProcessing((prev) => !prev); | |
}; | |
// Test draw box function | |
const handleTestDrawBox = () => { | |
if (!canvasRef.current) return; | |
const canvas = canvasRef.current; | |
const ctx = canvas.getContext("2d"); | |
if (!ctx) return; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
ctx.strokeStyle = "#FF00FF"; | |
ctx.lineWidth = 4; | |
ctx.strokeRect(40, 40, Math.max(40,canvas.width/4), Math.max(40,canvas.height/4)); | |
ctx.font = "20px Arial"; | |
ctx.fillStyle = "#FF00FF"; | |
ctx.fillText("Test Box", 50, 35); | |
}; | |
return ( | |
<div className="absolute inset-0 text-white"> | |
<div className="fixed top-0 left-0 w-full bg-gray-900 text-white text-center py-2 z-50"> | |
{isLoading ? "Loading model..." : isLoaded ? "Model loaded" : modelError ? `Model error: ${modelError}` : "Model not loaded"} | |
</div> | |
<div className="text-center text-sm text-blue-300 mt-2">{inferenceStatus}</div> | |
<div className="flex flex-col items-center justify-center h-full w-full"> | |
{/* Mode Selector */} | |
<div className="mb-6"> | |
<div className="flex space-x-4"> | |
{MODES.map((m) => ( | |
<button | |
key={m} | |
className={`px-6 py-2 rounded-lg font-semibold transition-all duration-200 ${ | |
mode === m ? "bg-blue-600 text-white" : "bg-gray-700 text-gray-300 hover:bg-blue-500" | |
}`} | |
onClick={() => setMode(m)} | |
> | |
{m} | |
</button> | |
))} | |
</div> | |
</div> | |
{/* Mode Content */} | |
<div className="w-full max-w-2xl flex-1 flex flex-col items-center justify-center"> | |
{mode === "Webcam" && ( | |
<div className="w-full text-center flex flex-col items-center"> | |
<div className="mb-4 w-full max-w-xl"> | |
<label className="block text-left mb-2 font-medium">Detection Prompt:</label> | |
<textarea | |
className="w-full p-2 rounded-lg text-black" | |
rows={3} | |
value={prompt} | |
onChange={(e) => setPrompt(e.target.value)} | |
/> | |
</div> | |
<div className="relative w-full max-w-xl"> | |
<video | |
ref={videoRef} | |
autoPlay | |
muted | |
playsInline | |
className="w-full rounded-lg shadow-lg mb-2" | |
style={{ background: "#222" }} | |
/> | |
<canvas | |
ref={canvasRef} | |
className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
style={{ zIndex: 10, pointerEvents: "none" }} | |
/> | |
</div> | |
{processing && <div className="text-blue-400 mt-2">Processing frame...</div>} | |
{error && <div className="text-red-400 mt-2">Error: {error}</div>} | |
</div> | |
)} | |
{mode === "URL" && ( | |
<div className="w-full text-center flex flex-col items-center"> | |
<p className="mb-4">Enter a video stream URL (e.g., HTTP MP4, MJPEG, HLS, etc.):</p> | |
<div className="flex w-full max-w-xl mb-4"> | |
<input | |
type="text" | |
className="flex-1 px-4 py-2 rounded-l-lg text-black" | |
value={inputUrl} | |
onChange={(e) => setInputUrl(e.target.value)} | |
placeholder="Paste video URL here" | |
/> | |
<button | |
className="px-4 py-2 rounded-r-lg bg-blue-600 text-white font-semibold" | |
onClick={() => setVideoUrl(inputUrl)} | |
> | |
Load | |
</button> | |
</div> | |
<div className="mb-4 w-full max-w-xl"> | |
<label className="block text-left mb-2 font-medium">Detection Prompt:</label> | |
<textarea | |
className="w-full p-2 rounded-lg text-black" | |
rows={3} | |
value={prompt} | |
onChange={(e) => setPrompt(e.target.value)} | |
/> | |
</div> | |
<div className="relative w-full max-w-xl"> | |
<video | |
ref={videoRef} | |
src={videoUrl} | |
controls | |
autoPlay | |
loop | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-0" | |
style={{ background: "#222" }} | |
/> | |
<video | |
ref={overlayVideoRef} | |
src={videoUrl} | |
controls={false} | |
autoPlay | |
loop | |
muted | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-10 opacity-60 pointer-events-none" | |
style={{ background: "#222" }} | |
/> | |
<canvas | |
ref={canvasRef} | |
className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
style={{ zIndex: 20, pointerEvents: "none" }} | |
/> | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-blue-600 text-white font-semibold relative z-30" | |
onClick={handleToggleUrlProcessing} | |
> | |
{urlProcessing ? "Stop Processing" : "Start Processing"} | |
</button> | |
</div> | |
{processing && <div className="text-blue-400 mt-2">Processing frame...</div>} | |
{error && <div className="text-red-400 mt-2">Error: {error}</div>} | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-gray-600 text-white font-semibold" | |
onClick={handleTestDrawBox} | |
> | |
Test Draw Box | |
</button> | |
<div className="mt-2 p-2 bg-gray-800 rounded text-xs"> | |
<div>Canvas: {canvasDims ? `${canvasDims.w}x${canvasDims.h}` : "-"} | Video: {videoDims ? `${videoDims.w}x${videoDims.h}` : "-"}</div> | |
<div>Raw Model Output:</div> | |
<pre className="overflow-x-auto max-h-32 whitespace-pre-wrap">{debugOutput}</pre> | |
</div> | |
</div> | |
)} | |
{mode === "File" && ( | |
<div className="w-full text-center flex flex-col items-center"> | |
<div className="mb-4 w-full max-w-xl"> | |
<label className="block text-left mb-2 font-medium">Detection Prompt:</label> | |
<textarea | |
className="w-full p-2 rounded-lg text-black" | |
rows={3} | |
value={prompt} | |
onChange={(e) => setPrompt(e.target.value)} | |
/> | |
</div> | |
<div className="mb-4 w-full max-w-xl"> | |
<input | |
type="file" | |
accept="image/*,video/*" | |
onChange={handleFileChange} | |
className="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700" | |
/> | |
</div> | |
{/* Show uploaded image */} | |
{uploadedFile && isImageFile(uploadedFile) && ( | |
<div className="relative w-full max-w-xl"> | |
<img | |
ref={imageRef} | |
src={uploadedUrl} | |
alt="Uploaded" | |
className="w-full rounded-lg shadow-lg mb-2" | |
style={{ background: "#222" }} | |
/> | |
<canvas | |
ref={canvasRef} | |
className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
style={{ zIndex: 10, pointerEvents: "none" }} | |
/> | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-blue-600 text-white font-semibold" | |
onClick={handleProcessImage} | |
disabled={processing} | |
> | |
{processing ? "Processing..." : imageProcessed ? "Reprocess Image" : "Process Image"} | |
</button> | |
</div> | |
)} | |
{/* Show uploaded video */} | |
{uploadedFile && isVideoFile(uploadedFile) && ( | |
<div className="relative w-full max-w-xl"> | |
<video | |
ref={videoRef} | |
src={uploadedUrl} | |
controls | |
autoPlay | |
loop | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-0" | |
style={{ background: "#222" }} | |
/> | |
<video | |
ref={overlayVideoRef} | |
src={uploadedUrl} | |
controls={false} | |
autoPlay | |
loop | |
muted | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-10 opacity-60 pointer-events-none" | |
style={{ background: "#222" }} | |
/> | |
<canvas | |
ref={canvasRef} | |
className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
style={{ zIndex: 20, pointerEvents: "none" }} | |
/> | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-blue-600 text-white font-semibold relative z-30" | |
onClick={handleToggleVideoProcessing} | |
> | |
{videoProcessing ? "Stop Processing" : "Start Processing"} | |
</button> | |
</div> | |
)} | |
{/* Show example video if no file uploaded */} | |
{!uploadedFile && ( | |
<div className="relative w-full max-w-xl"> | |
<video | |
ref={videoRef} | |
src={EXAMPLE_VIDEO_URL} | |
controls | |
autoPlay | |
loop | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-0" | |
style={{ background: "#222" }} | |
/> | |
<video | |
ref={overlayVideoRef} | |
src={EXAMPLE_VIDEO_URL} | |
controls={false} | |
autoPlay | |
loop | |
muted | |
className="w-full rounded-lg shadow-lg mb-2 absolute top-0 left-0 z-10 opacity-60 pointer-events-none" | |
style={{ background: "#222" }} | |
/> | |
<canvas | |
ref={canvasRef} | |
className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
style={{ zIndex: 20, pointerEvents: "none" }} | |
/> | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-blue-600 text-white font-semibold relative z-30" | |
onClick={handleToggleExampleProcessing} | |
> | |
{exampleProcessing ? "Stop Processing" : "Start Processing"} | |
</button> | |
</div> | |
)} | |
{processing && <div className="text-blue-400 mt-2">Processing frame...</div>} | |
{error && <div className="text-red-400 mt-2">Error: {error}</div>} | |
<button | |
className="mt-4 px-6 py-2 rounded-lg bg-gray-600 text-white font-semibold" | |
onClick={handleTestDrawBox} | |
> | |
Test Draw Box | |
</button> | |
<div className="mt-2 p-2 bg-gray-800 rounded text-xs"> | |
<div>Canvas: {canvasDims ? `${canvasDims.w}x${canvasDims.h}` : "-"} | Video: {videoDims ? `${videoDims.w}x${videoDims.h}` : "-"}</div> | |
<div>Raw Model Output:</div> | |
<pre className="overflow-x-auto max-h-32 whitespace-pre-wrap">{debugOutput}</pre> | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
</div> | |
); | |
} |