tiny-trigger / frontend /src /modules /detector /DetectorPanel.tsx
Javier Montalvo
Add demo video link to README; frontend and server updates
015fde7
Raw
History Blame Contribute Delete
8.92 kB
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 {
// silently ignore if demo not available
} 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>
)
}