mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 20:11:10 +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:
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
||||
import { endpoints } from '../../api'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
|
||||
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||
import { parseAnnotationTime } from './time'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
@@ -12,6 +13,8 @@ interface Props {
|
||||
selectedClassNum: number
|
||||
currentTime: number
|
||||
annotations: AnnotationListItem[]
|
||||
onZoomChange?: (zoom: number) => void
|
||||
onCursorChange?: (nx: number, ny: number) => void
|
||||
}
|
||||
|
||||
export interface CanvasEditorHandle {
|
||||
@@ -28,28 +31,60 @@ interface DragState {
|
||||
handle?: string
|
||||
}
|
||||
|
||||
interface LabelChip {
|
||||
leftPct: number
|
||||
topPct: number
|
||||
color: string
|
||||
name: string
|
||||
conf: number
|
||||
combatReady: boolean
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 6
|
||||
const MIN_BOX_SIZE = 12
|
||||
|
||||
const AFFILIATION_COLORS: Record<number, string> = {
|
||||
0: '#FFD700',
|
||||
1: '#228be6',
|
||||
2: '#fa5252',
|
||||
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||
|
||||
function affiliationIcon(hex: string) {
|
||||
const up = hex.toUpperCase()
|
||||
if (HOSTILE_HEXES.has(up)) {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
|
||||
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (FRIENDLY_HEXES.has(up)) {
|
||||
return (
|
||||
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
|
||||
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
const cursorRafRef = useRef<number | null>(null)
|
||||
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
deleteSelected() {
|
||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
|
||||
const loadImage = useCallback(() => {
|
||||
if (isVideo) {
|
||||
// Use natural size based on container; no image load
|
||||
imgRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
return () => ro.disconnect()
|
||||
}, [isVideo])
|
||||
|
||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
||||
x: nx * imgSize.w * zoom + pan.x,
|
||||
y: ny * imgSize.h * zoom + pan.y,
|
||||
}), [imgSize, zoom, pan])
|
||||
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||
|
||||
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||
useEffect(() => () => {
|
||||
if (cursorRafRef.current != null) {
|
||||
cancelAnimationFrame(cursorRafRef.current)
|
||||
cursorRafRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
||||
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||
}), [imgSize, zoom, pan])
|
||||
|
||||
const getTimeWindowDetections = useCallback((): Detection[] => {
|
||||
if (media.mediaType !== MediaType.Video) return []
|
||||
if (annotation) return []
|
||||
const timeTicks = currentTime * 10_000_000
|
||||
return annotations
|
||||
.filter(a => {
|
||||
const sec = parseAnnotationTime(a.time)
|
||||
if (sec == null) return false
|
||||
return Math.abs(sec * 10_000_000 - timeTicks) < 2_000_000
|
||||
})
|
||||
.flatMap(a => a.detections)
|
||||
}, [media.mediaType, annotation, annotations, currentTime])
|
||||
|
||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||
]
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
|
||||
const timeWindowDets = getTimeWindowDetections()
|
||||
const allDets = [...detections, ...timeWindowDets]
|
||||
const chips: LabelChip[] = []
|
||||
|
||||
allDets.forEach((det, i) => {
|
||||
const isSelected = selected.has(i) && i < detections.length
|
||||
const isOwn = i < detections.length
|
||||
const isSelected = selected.has(i) && isOwn
|
||||
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||
const w = det.width * imgSize.w * zoom
|
||||
@@ -160,45 +225,51 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
ctx.strokeRect(cx, cy, w, h)
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.globalAlpha = 0.1
|
||||
ctx.globalAlpha = 0.06
|
||||
ctx.fillRect(cx, cy, w, h)
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const name = det.label || getClassNameFallback(det.classNum)
|
||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
||||
const label = `${name}${modeSuffix}${confSuffix}`
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
const metrics = ctx.measureText(label)
|
||||
const padX = 3
|
||||
const labelH = 14
|
||||
const labelW = metrics.width + padX * 2
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(cx, cy - labelH, labelW, labelH)
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillText(label, cx + padX, cy - 3)
|
||||
|
||||
if (det.combatReadiness === 1) {
|
||||
ctx.fillStyle = '#40c057'
|
||||
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||
const legLen = 8
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||
ctx.moveTo(cx + w - legLen, cy); ctx.lineTo(cx + w, cy); ctx.lineTo(cx + w, cy + legLen)
|
||||
ctx.moveTo(cx + w, cy + h - legLen); ctx.lineTo(cx + w, cy + h); ctx.lineTo(cx + w - legLen, cy + h)
|
||||
ctx.moveTo(cx + legLen, cy + h); ctx.lineTo(cx, cy + h); ctx.lineTo(cx, cy + h - legLen)
|
||||
ctx.strokeStyle = color
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = 1
|
||||
}
|
||||
|
||||
if (isOwn) {
|
||||
const container = containerRef.current
|
||||
if (container && container.clientWidth && container.clientHeight) {
|
||||
chips.push({
|
||||
leftPct: (cx / container.clientWidth) * 100,
|
||||
topPct: (cy / container.clientHeight) * 100,
|
||||
color,
|
||||
name: det.label || getClassNameFallback(det.classNum),
|
||||
conf: det.confidence,
|
||||
combatReady: det.combatReadiness === 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
const handles = getHandles(cx, cy, w, h)
|
||||
handles.forEach(hp => {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillStyle = '#FF9D3D'
|
||||
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
ctx.strokeStyle = color
|
||||
ctx.strokeStyle = '#0A0D10'
|
||||
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (drawRect) {
|
||||
ctx.strokeStyle = '#fd7e14'
|
||||
ctx.strokeStyle = '#FF9D3D'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
|
||||
|
||||
// Only setState when chips actually changed — prevents a render storm
|
||||
// during video playback (draw runs on every time-update; without this
|
||||
// guard React would commit a new array reference on every paint).
|
||||
setLabelChips(prev => {
|
||||
if (prev.length !== chips.length) return chips
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const a = prev[i], b = chips[i]
|
||||
if (
|
||||
a.leftPct !== b.leftPct || a.topPct !== b.topPct ||
|
||||
a.color !== b.color || a.name !== b.name ||
|
||||
a.conf !== b.conf || a.combatReady !== b.combatReady
|
||||
) return chips
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [detections, selected, zoom, pan, imgSize, drawRect, isVideo, getTimeWindowDetections])
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(draw)
|
||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
return () => obs.disconnect()
|
||||
}, [draw])
|
||||
|
||||
const getTimeWindowDetections = (): Detection[] => {
|
||||
if (media.mediaType !== MediaType.Video) return []
|
||||
if (annotation) return []
|
||||
const timeTicks = currentTime * 10_000_000
|
||||
return annotations
|
||||
.filter(a => {
|
||||
if (!a.time) return false
|
||||
const parts = a.time.split(':').map(Number)
|
||||
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
|
||||
return Math.abs(annTime - timeTicks) < 2_000_000
|
||||
})
|
||||
.flatMap(a => a.detections)
|
||||
}
|
||||
|
||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||
]
|
||||
|
||||
const hitTest = (cx: number, cy: number) => {
|
||||
for (let i = detections.length - 1; i >= 0; i--) {
|
||||
const d = detections[i]
|
||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragState) return
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
|
||||
if (onCursorChange && imgSize.w && imgSize.h) {
|
||||
const nx = (mx - pan.x) / (imgSize.w * zoom)
|
||||
const ny = (my - pan.y) / (imgSize.h * zoom)
|
||||
if (nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1) {
|
||||
cursorLatestRef.current = { x: nx, y: ny }
|
||||
if (cursorRafRef.current == null) {
|
||||
cursorRafRef.current = requestAnimationFrame(() => {
|
||||
const v = cursorLatestRef.current
|
||||
cursorRafRef.current = null
|
||||
if (v) onCursorChange(v.x, v.y)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dragState) return
|
||||
|
||||
if (dragState.type === 'draw') {
|
||||
setDrawRect({
|
||||
x: Math.min(dragState.startX, mx),
|
||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{labelChips.map((chip, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bbox-label"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${chip.leftPct}%`,
|
||||
top: `calc(${chip.topPct}% - 26px)`,
|
||||
borderColor: hexToRgba(chip.color, 0.6),
|
||||
}}
|
||||
>
|
||||
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
|
||||
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
|
||||
<span style={{ color: chip.color }}>{chip.name}</span>
|
||||
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user