mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:11:10 +00:00
f754afff46
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).
171 lines
7.5 KiB
TypeScript
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>
|
|
)
|
|
}
|