mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 12:11:11 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user