annotations v2: redesign
ci/woodpecker/push/build-arm Pipeline failed

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).
This commit is contained in:
Armen Rohalov
2026-05-28 02:28:10 +03:00
parent cfffb4bdd7
commit f754afff46
17 changed files with 1279 additions and 415 deletions
+124 -68
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api, createSSE, endpoints } from '../../api'
import { getClassColor } from '../../class-colors'
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props {
@@ -14,10 +14,46 @@ interface Props {
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()
const [detecting, setDetecting] = useState(false)
const [detectLog, setDetectLog] = useState<string[]>([])
useEffect(() => {
if (!media) return
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
})
}, [media, onAnnotationsUpdate])
const handleDetect = async () => {
if (!media) return
setDetecting(true)
setDetectLog(['Starting AI detection...'])
try {
await api.post(endpoints.detect.media(media.id))
setDetectLog(prev => [...prev, 'Detection complete.'])
} catch (e: any) {
setDetectLog(prev => [...prev, `Error: ${e.message}`])
}
}
const totals = useMemo(() => ({
total: annotations.length,
empty: annotations.filter(a => a.detections.length === 0).length,
}), [annotations])
const getRowGradient = (ann: AnnotationListItem) => {
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
const stops = ann.detections.map((d, i) => {
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
const alpha = Math.min(1, d.confidence)
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
})
return `linear-gradient(to right, ${stops.join(', ')})`
}
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
return (
<div className="flex flex-col h-full">
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
<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
onClick={handleDetect}
disabled={!media}
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
>
{t('annotations.detect')}
<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
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
disabled={!selectedAnnotation}
title="Download annotation"
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
>
<FaDownload size={12} />
<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="flex-1 overflow-y-auto">
{annotations.map(ann => (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
}`}
style={{ background: getRowGradient(ann) }}
>
<div className="flex items-center justify-between">
<span className="text-az-text font-mono">{ann.time || ''}</span>
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
<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>
</div>
))}
)
})}
{annotations.length === 0 && (
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
)}
</div>
{detecting && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
</div>
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
Close
</button>
</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>
)
}