Files
ui/src/features/annotations/AnnotationsSidebar.tsx
T
Armen Rohalov f754afff46
ci/woodpecker/push/build-arm Pipeline failed
annotations v2: redesign
Reskin to v2 surface/accent tokens + JetBrains Mono headings to match
_docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored
annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection
banner, multi-band gradient rows in the annotations sidebar, class-distribution
summary footer, and DOM-overlay bbox labels with affiliation icon + readiness
dot. Split VideoPlayer chrome out into the page-level controls row
(transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber
component; player events replace 200ms polling.

Other:
- Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV).
- Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash.
- Extract hexToRgba to src/class-colors and time helpers to
  src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar /
  AnnotationsPage).
- CanvasEditor: shallow-compare label chips before commit, NaN-guard
  annotation-time parser, cancel cursor RAF on unmount.
- AnnotationsPage: track AI-banner close timer, push initial volume to the
  <video> on media change, drop the duplicate parent muted state.
- Fixed sidebar widths (resize handles removed per design).
2026-05-28 02:28:10 +03:00

171 lines
7.5 KiB
TypeScript

import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api, createSSE, endpoints } from '../../api'
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props {
media: Media | null
annotations: AnnotationListItem[]
selectedAnnotation: AnnotationListItem | null
onSelect: (ann: AnnotationListItem) => void
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
onDownload?: (ann: AnnotationListItem) => void
}
function getRowGradient(ann: AnnotationListItem): string {
if (ann.detections.length === 0) {
return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))'
}
if (ann.detections.length === 1) {
const c = getClassColor(ann.detections[0].classNum)
return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)`
}
const n = ann.detections.length
const bandWidth = 100 / n
const stops: string[] = []
ann.detections.forEach((d, i) => {
const c = getClassColor(d.classNum)
const start = i * bandWidth
const mid = start + bandWidth * 0.6
const end = (i + 1) * bandWidth
stops.push(`${hexToRgba(c, 0.50)} ${start}%`)
stops.push(`${hexToRgba(c, 0.10)} ${mid}%`)
if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`)
})
return `linear-gradient(90deg, ${stops.join(', ')})`
}
interface ClassAgg { classNum: number; color: string; count: number }
function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] {
const counts = new Map<number, number>()
for (const ann of annotations) {
for (const d of ann.detections) {
counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1)
}
}
return [...counts.entries()]
.map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count }))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
const { t } = useTranslation()
useEffect(() => {
if (!media) return
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
if (event.mediaId === media.id) {
api.get<PaginatedResponse<AnnotationListItem>>(
endpoints.annotations.annotationsByMedia(media.id),
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
}
})
}, [media, onAnnotationsUpdate])
const totals = useMemo(() => ({
total: annotations.length,
empty: annotations.filter(a => a.detections.length === 0).length,
}), [annotations])
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
return (
<div className="flex flex-col h-full bg-surface-1">
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.title')}</span>
<span className="mono text-[10px] text-text-muted">{String(annotations.length).padStart(2, '0')}</span>
</div>
<div className="flex items-center gap-1">
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
</button>
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
</button>
</div>
</div>
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
<span className="micro">{t('annotations.colTime')}</span>
<span className="micro">{t('annotations.colClass')}</span>
<span className="micro">{t('annotations.colConf')}</span>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{annotations.map(ann => {
const isSelected = selectedAnnotation?.id === ann.id
const isEmpty = ann.detections.length === 0
const first = ann.detections[0]
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
return (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`ann-row${isSelected ? ' active' : ''}`}
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
>
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
{ann.time || '—'}
</span>
{isEmpty
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
}
<div className="flex items-center gap-1.5">
{isSelected && !isEmpty && onDownload && (
<button
onClick={e => { e.stopPropagation(); onDownload(ann) }}
className="ibtn"
style={{ width: 18, height: 18 }}
title="Download annotation"
>
<FaDownload size={9} />
</button>
)}
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
</span>
</div>
</div>
)
})}
{annotations.length === 0 && (
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
)}
</div>
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
<div className="flex items-center justify-between mb-2">
<span className="micro">{t('annotations.summary')}</span>
<span className="mono text-[10px] text-text-muted">
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
</span>
</div>
{classDist.length > 0 && (
<>
<div className="flex items-center gap-1 h-2">
{classDist.map(c => (
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
))}
</div>
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
{classDist.map(c => (
<span key={c.classNum} className="flex items-center gap-1">
<span style={{ color: c.color }}></span> {c.count}
</span>
))}
</div>
</>
)}
</div>
</div>
)
}