| import { useRef, useState } from "react" |
| import { ChevronDown, Play, RotateCcw, Upload, Loader2, Clapperboard } from "lucide-react" |
| import { useDashboard } from "@/lib/dashboard" |
| import { Button } from "@/components/ui/button" |
| import { Label } from "@/components/ui/label" |
| import { Input } from "@/components/ui/input" |
| import { Textarea } from "@/components/ui/textarea" |
| import { Slider } from "@/components/ui/slider" |
| import { Separator } from "@/components/ui/separator" |
| import { cn } from "@/lib/utils" |
|
|
| const DETECTOR_MODELS = [ |
| { label: "Nano", value: "yoloe-26n-seg.pt" }, |
| { label: "Small", value: "yoloe-26s-seg.pt" }, |
| { label: "Medium", value: "yoloe-26m-seg.pt" }, |
| { label: "Large", value: "yoloe-26l-seg.pt" }, |
| { label: "XLarge", value: "yoloe-26x-seg.pt" }, |
| ] |
|
|
| const IMAGE_SIZES = [320, 640, 960, 1280] |
|
|
| function SliderRow({ |
| label, |
| value, |
| display, |
| min, |
| max, |
| step, |
| onChange, |
| }: { |
| label: string |
| value: number |
| display: string |
| min: number |
| max: number |
| step: number |
| onChange: (v: number) => void |
| }) { |
| return ( |
| <div className="space-y-2"> |
| <div className="flex items-baseline justify-between"> |
| <Label>{label}</Label> |
| <span className="font-mono text-xs text-foreground tabular-nums">{display}</span> |
| </div> |
| <Slider |
| value={[value]} |
| min={min} |
| max={max} |
| step={step} |
| onValueChange={([v]) => onChange(v)} |
| /> |
| </div> |
| ) |
| } |
|
|
| export function DetectorPanel() { |
| const { params, setParam, videoFile, setVideo, run, running } = useDashboard() |
| const fileRef = useRef<HTMLInputElement>(null) |
| const [dragging, setDragging] = useState(false) |
| const [advanced, setAdvanced] = useState(false) |
| const [loadingDemo, setLoadingDemo] = useState(false) |
|
|
| async function loadDemo() { |
| setLoadingDemo(true) |
| try { |
| const r = await fetch("/demo-video") |
| const blob = await r.blob() |
| setVideo(new File([blob], "tiny-trigger-demo.mp4", { type: "video/mp4" })) |
| } catch { |
| |
| } finally { |
| setLoadingDemo(false) |
| } |
| } |
|
|
| return ( |
| <div className="space-y-5"> |
| {/* dropzone */} |
| <div |
| onClick={() => fileRef.current?.click()} |
| onDragOver={(e) => { |
| e.preventDefault() |
| setDragging(true) |
| }} |
| onDragLeave={() => setDragging(false)} |
| onDrop={(e) => { |
| e.preventDefault() |
| setDragging(false) |
| const f = e.dataTransfer.files?.[0] |
| if (f) setVideo(f) |
| }} |
| className={cn( |
| "group relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors", |
| dragging |
| ? "border-primary/60 bg-primary/5" |
| : "border-border hover:border-white/20 hover:bg-white/[0.02]", |
| )} |
| > |
| <input |
| ref={fileRef} |
| type="file" |
| accept="video/*" |
| className="hidden" |
| onChange={(e) => e.target.files?.[0] && setVideo(e.target.files[0])} |
| /> |
| <div className="flex size-9 items-center justify-center rounded-full border border-border bg-white/5 text-muted-foreground group-hover:text-foreground"> |
| <Upload className="size-4" /> |
| </div> |
| {videoFile ? ( |
| <div className="min-w-0"> |
| <p className="truncate font-mono text-xs text-foreground">{videoFile.name}</p> |
| <p className="text-[0.6875rem] text-muted-foreground"> |
| {(videoFile.size / 1e6).toFixed(1)} MB · click to replace |
| </p> |
| </div> |
| ) : ( |
| <div> |
| <p className="text-sm text-foreground">Drop a clip or click to upload</p> |
| <p className="text-[0.6875rem] text-muted-foreground">mp4 · webm · mov</p> |
| </div> |
| )} |
| </div> |
| |
| <Button |
| variant="outline" |
| size="sm" |
| className="w-full gap-2 text-muted-foreground" |
| onClick={loadDemo} |
| disabled={loadingDemo} |
| > |
| {loadingDemo ? <Loader2 className="size-3.5 animate-spin" /> : <Clapperboard className="size-3.5" />} |
| Use demo video |
| </Button> |
| |
| <div className="space-y-2"> |
| <Label htmlFor="classes">Open-vocabulary classes</Label> |
| <Textarea |
| id="classes" |
| value={params.classes} |
| onChange={(e) => setParam("classes", e.target.value)} |
| className="min-h-16 font-mono text-xs leading-relaxed" |
| placeholder="person, cat, car, chair, cup…" |
| /> |
| </div> |
| |
| <div className="space-y-2"> |
| <Label htmlFor="model">Detector model</Label> |
| <select |
| id="model" |
| value={params.modelName} |
| onChange={(e) => setParam("modelName", e.target.value)} |
| className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" |
| > |
| {DETECTOR_MODELS.map((model) => ( |
| <option key={model.value} value={model.value}> |
| {model.label} |
| </option> |
| ))} |
| </select> |
| </div> |
| |
| <div className="space-y-4"> |
| <SliderRow |
| label="Confidence" |
| value={params.confidence} |
| display={params.confidence.toFixed(2)} |
| min={0.05} |
| max={0.9} |
| step={0.05} |
| onChange={(v) => setParam("confidence", Number(v.toFixed(2)))} |
| /> |
| <SliderRow |
| label="Sample interval" |
| value={params.sampleIntervalSec} |
| display={`${params.sampleIntervalSec.toFixed(2)}s`} |
| min={0.25} |
| max={5} |
| step={0.25} |
| onChange={(v) => setParam("sampleIntervalSec", Number(v.toFixed(2)))} |
| /> |
| <SliderRow |
| label="Max frames" |
| value={params.maxFrames} |
| display={`${params.maxFrames}`} |
| min={10} |
| max={300} |
| step={10} |
| onChange={(v) => setParam("maxFrames", v)} |
| /> |
| </div> |
| |
| <div> |
| <button |
| onClick={() => setAdvanced((v) => !v)} |
| className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground hover:text-foreground" |
| > |
| Advanced |
| <ChevronDown className={cn("size-3.5 transition-transform", advanced && "rotate-180")} /> |
| </button> |
| {advanced && ( |
| <div className="mt-3 space-y-3 reveal"> |
| <div className="grid grid-cols-2 gap-3"> |
| <div className="space-y-1.5"> |
| <Label htmlFor="device">Device</Label> |
| <Input |
| id="device" |
| value={params.device} |
| onChange={(e) => setParam("device", e.target.value)} |
| className="font-mono text-xs" |
| placeholder="auto / cuda:0 / cpu" |
| /> |
| </div> |
| <div className="space-y-1.5"> |
| <Label htmlFor="imgsz">Image size</Label> |
| <select |
| id="imgsz" |
| value={params.imageSize} |
| onChange={(e) => setParam("imageSize", Number(e.target.value))} |
| className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" |
| > |
| {IMAGE_SIZES.map((size) => ( |
| <option key={size} value={size}> |
| {size} |
| </option> |
| ))} |
| </select> |
| </div> |
| </div> |
| <div className="space-y-1.5"> |
| <Label htmlFor="maxdet">Max detections</Label> |
| <Input |
| id="maxdet" |
| type="number" |
| value={params.maxDetections || ""} |
| onChange={(e) => setParam("maxDetections", Number(e.target.value) || 0)} |
| className="font-mono text-xs" |
| placeholder="default" |
| /> |
| </div> |
| </div> |
| )} |
| </div> |
| |
| <Separator /> |
| |
| <div className="flex gap-2"> |
| <Button |
| variant="secondary" |
| size="default" |
| className="flex-1" |
| onClick={() => { |
| setVideo(null) |
| }} |
| disabled={running} |
| > |
| <RotateCcw className="size-4" /> |
| Reset |
| </Button> |
| <Button className="flex-[2]" onClick={run} disabled={running}> |
| {running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />} |
| {running ? "Detecting…" : "Run detection"} |
| </Button> |
| </div> |
| </div> |
| ) |
| } |
|
|