mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 18:51:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f754afff46 |
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, ProtectedRoute } from './auth'
|
import { AuthProvider, ProtectedRoute } from './auth'
|
||||||
import { Header, FlightProvider } from './components'
|
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
|
||||||
import { LoginPage } from './features/login'
|
import { LoginPage } from './features/login'
|
||||||
import { FlightsPage } from './features/flights'
|
import { FlightsPage } from './features/flights'
|
||||||
import { AnnotationsPage } from './features/annotations'
|
import { AnnotationsPage } from './features/annotations'
|
||||||
@@ -18,6 +18,7 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FlightProvider>
|
<FlightProvider>
|
||||||
|
<SavedAnnotationsProvider>
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -31,6 +32,7 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SavedAnnotationsProvider>
|
||||||
</FlightProvider>
|
</FlightProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,29 @@ export function __resetBootstrapInflightForTests(): void {
|
|||||||
bootstrapInflight = null
|
bootstrapInflight = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev-only escape hatch: `VITE_DEV_AUTH_BYPASS=true` skips the backend round
|
||||||
|
// trip and injects a fake admin user so the SPA renders authenticated. Lives
|
||||||
|
// in this file so the bypass is gated by the same effect that owns auth state;
|
||||||
|
// the import.meta.env check is also tree-shaken out of production builds when
|
||||||
|
// the flag is unset at build time.
|
||||||
|
const DEV_BYPASS_USER: AuthUser = {
|
||||||
|
id: 'dev-bypass',
|
||||||
|
email: 'dev@azaion.local',
|
||||||
|
name: 'Dev Bypass',
|
||||||
|
role: 'admin',
|
||||||
|
// Permission codes are short identifiers checked via hasPermission(code) —
|
||||||
|
// currently used by the Header to gate the nav tabs (FL, ANN, DATASET, ADM).
|
||||||
|
permissions: ['FL', 'ANN', 'DATASET', 'ADM'],
|
||||||
|
}
|
||||||
|
|
||||||
async function runBootstrap(): Promise<AuthUser | null> {
|
async function runBootstrap(): Promise<AuthUser | null> {
|
||||||
|
// Gated on import.meta.env.DEV so a leaked VITE_DEV_AUTH_BYPASS=true in a
|
||||||
|
// production build cannot grant admin access. Vite tree-shakes the entire
|
||||||
|
// branch when DEV is false at build time.
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') {
|
||||||
|
setToken('dev-bypass-token')
|
||||||
|
return DEV_BYPASS_USER
|
||||||
|
}
|
||||||
// POST refresh with credentials — the whole point of the consolidation. Goes
|
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||||
// through fetch() directly (not api.post) because api.post does not thread
|
// through fetch() directly (not api.post) because api.post does not thread
|
||||||
// credentials:'include'; widening api.post would change CORS posture for
|
// credentials:'include'; widening api.post would change CORS posture for
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
|
|||||||
const base = classNum % 20
|
const base = classNum % 20
|
||||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const h = hex.replace('#', '')
|
||||||
|
const r = parseInt(h.slice(0, 2), 16)
|
||||||
|
const g = parseInt(h.slice(2, 4), 16)
|
||||||
|
const b = parseInt(h.slice(4, 6), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export {
|
|||||||
getClassColor,
|
getClassColor,
|
||||||
getPhotoModeSuffix,
|
getPhotoModeSuffix,
|
||||||
getClassNameFallback,
|
getClassNameFallback,
|
||||||
|
hexToRgba,
|
||||||
FALLBACK_CLASS_NAMES,
|
FALLBACK_CLASS_NAMES,
|
||||||
} from './classColors'
|
} from './classColors'
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
|
||||||
import { FaRegSnowflake } from 'react-icons/fa'
|
|
||||||
import { api, endpoints } from '../api'
|
import { api, endpoints } from '../api'
|
||||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||||
// Importing through the 06_annotations barrel would create a cycle
|
// Importing through the 06_annotations barrel would create a cycle
|
||||||
@@ -60,44 +58,72 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
|||||||
}
|
}
|
||||||
}, [classes, photoMode, selectedClassNum, onSelect])
|
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||||
|
|
||||||
|
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
{ value: 0, label: t('annotations.regular') },
|
||||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
{ value: 20, label: t('annotations.winter') },
|
||||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
{ value: 40, label: t('annotations.night') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-az-border p-2">
|
<div className="border-t border-border-hair">
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
{/* Section header */}
|
||||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="sect-head">{t('annotations.classes')}</span>
|
||||||
key={c.id}
|
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
|
||||||
onClick={() => onSelect(c.id)}
|
|
||||||
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
|
|
||||||
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getClassColor(c.id) }} />
|
|
||||||
<span className="text-az-muted">{i + 1}.</span>
|
|
||||||
<span className="truncate">{c.name}</span>
|
|
||||||
<span className="text-az-muted ml-auto">{c.shortName}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
|
||||||
|
<span className="micro">{t('annotations.colNum')}</span>
|
||||||
|
<span className="micro">{t('annotations.colName')}</span>
|
||||||
|
<span className="micro">{t('annotations.colKey')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class rows */}
|
||||||
|
<div>
|
||||||
|
{modeClasses.map((c, i) => {
|
||||||
|
const isActive = selectedClassNum === c.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
|
||||||
|
className={`class-row${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="swatch" style={{ background: getClassColor(c.id) }} />
|
||||||
|
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="kbd">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PhotoMode segmented control */}
|
||||||
|
<div className="p-3 border-t border-border-hair">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="micro">{t('annotations.photoMode')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
|
||||||
{modes.map(m => (
|
{modes.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
|
||||||
onClick={() => onPhotoModeChange(m.value)}
|
onClick={() => onPhotoModeChange(m.value)}
|
||||||
title={m.label}
|
|
||||||
className={`flex-1 flex items-center justify-center px-2 py-1 rounded text-base ${photoMode === m.value ? m.activeClass : `bg-az-bg ${m.iconColor} hover:brightness-125`}`}
|
|
||||||
>
|
>
|
||||||
{m.icon}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
|
|||||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
export { default as DetectionClasses } from './DetectionClasses'
|
export { default as DetectionClasses } from './DetectionClasses'
|
||||||
export { FlightProvider, useFlight } from './FlightContext'
|
export { FlightProvider, useFlight } from './FlightContext'
|
||||||
|
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
|
||||||
|
|||||||
@@ -1,38 +1,108 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useResizablePanel } from '../../hooks'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import MediaList from './MediaList'
|
import MediaList from './MediaList'
|
||||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
|
import Scrubber, { type ScrubberMark } from './Scrubber'
|
||||||
import { DetectionClasses, useFlight } from '../../components'
|
import { DetectionClasses, useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||||
import { captureThumbnails } from './thumbnail'
|
import { captureThumbnails } from './thumbnail'
|
||||||
|
import { formatTime, formatTicks, parseAnnotationTime } from './time'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||||
|
|
||||||
|
const FRAME_STEPS = [1, 5, 10, 30, 60]
|
||||||
|
|
||||||
|
const FAKE_LOG_LINES = [
|
||||||
|
'[tile 04/16] 2 candidates',
|
||||||
|
'[tile 05/16] 1 candidate (conf 0.94)',
|
||||||
|
'[filter] min_conf=0.25…',
|
||||||
|
]
|
||||||
|
|
||||||
export default function AnnotationsPage() {
|
export default function AnnotationsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
const [photoMode, setPhotoMode] = useState(0)
|
||||||
const [detections, setDetections] = useState<Detection[]>([])
|
const [detections, setDetections] = useState<Detection[]>([])
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
const [zoom, setZoom] = useState(1)
|
||||||
const rightPanel = useResizablePanel(200, 150, 350)
|
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [volume, setVolume] = useState(0.62)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [aiDetecting, setAiDetecting] = useState(false)
|
||||||
|
const [aiLog, setAiLog] = useState<string[]>([])
|
||||||
|
const [aiProgress, setAiProgress] = useState(0)
|
||||||
|
const aiStartRef = useRef<number>(0)
|
||||||
|
const aiCloseTimerRef = useRef<number | null>(null)
|
||||||
|
const [aiElapsed, setAiElapsed] = useState(0)
|
||||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||||
const { addMany } = useSavedAnnotations()
|
const { addMany } = useSavedAnnotations()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
|
|
||||||
|
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetections([])
|
setDetections([])
|
||||||
setSelectedAnnotation(null)
|
setSelectedAnnotation(null)
|
||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
setIsPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
}, [selectedMedia])
|
}, [selectedMedia])
|
||||||
|
|
||||||
|
// Push the page's initial volume into the <video> element once the player
|
||||||
|
// is mounted — otherwise the slider shows 62% while audio plays at 100%.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMedia || !isVideo) return
|
||||||
|
videoPlayerRef.current?.setVolume(volume)
|
||||||
|
// Only on media change — subsequent slider drags push via onVolumeChange.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedMedia, isVideo])
|
||||||
|
|
||||||
|
// AI detection fake-log progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aiDetecting) return
|
||||||
|
aiStartRef.current = performance.now()
|
||||||
|
setAiElapsed(0)
|
||||||
|
setAiLog([])
|
||||||
|
setAiProgress(0)
|
||||||
|
let i = 0
|
||||||
|
const logTimer = window.setInterval(() => {
|
||||||
|
if (i < FAKE_LOG_LINES.length) {
|
||||||
|
setAiLog(prev => [...prev, FAKE_LOG_LINES[i]])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}, 700)
|
||||||
|
const tickTimer = window.setInterval(() => {
|
||||||
|
setAiElapsed((performance.now() - aiStartRef.current) / 1000)
|
||||||
|
setAiProgress(p => Math.min(0.95, p + 0.04))
|
||||||
|
}, 100)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(logTimer)
|
||||||
|
window.clearInterval(tickTimer)
|
||||||
|
}
|
||||||
|
}, [aiDetecting])
|
||||||
|
|
||||||
|
const scrubberMarks = useMemo<ScrubberMark[]>(() => {
|
||||||
|
return annotations
|
||||||
|
.map(a => {
|
||||||
|
const sec = parseAnnotationTime(a.time)
|
||||||
|
if (sec == null) return null
|
||||||
|
const first = a.detections[0]
|
||||||
|
return { time: sec, color: first ? getClassColor(first.classNum) : '#9AA4B2' }
|
||||||
|
})
|
||||||
|
.filter((m): m is ScrubberMark => m !== null)
|
||||||
|
}, [annotations])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedMedia || !detections.length) return
|
if (!selectedMedia || !detections.length) return
|
||||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||||
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
|
|||||||
txtA.click()
|
txtA.click()
|
||||||
URL.revokeObjectURL(txtUrl)
|
URL.revokeObjectURL(txtUrl)
|
||||||
|
|
||||||
// Build the image: video frame or image with rectangles drawn
|
|
||||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||||
let w = 0, h = 0
|
let w = 0, h = 0
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
|
|||||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||||
setSelectedAnnotation(ann)
|
setSelectedAnnotation(ann)
|
||||||
setDetections(ann.detections)
|
setDetections(ann.detections)
|
||||||
if (ann.time) {
|
const sec = parseAnnotationTime(ann.time)
|
||||||
const parts = ann.time.split(':').map(Number)
|
if (sec != null) {
|
||||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
videoPlayerRef.current?.seek(sec)
|
||||||
videoPlayerRef.current?.seek(seconds)
|
setCurrentTime(sec)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
|
|||||||
setDetections(dets)
|
setDetections(dets)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
const handleAiDetect = useCallback(async () => {
|
||||||
|
if (!selectedMedia || aiDetecting) return
|
||||||
function formatTicks(seconds: number): string {
|
if (aiCloseTimerRef.current != null) {
|
||||||
const h = Math.floor(seconds / 3600)
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
aiCloseTimerRef.current = null
|
||||||
const s = Math.floor(seconds % 60)
|
|
||||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
|
||||||
}
|
}
|
||||||
|
setAiDetecting(true)
|
||||||
|
try {
|
||||||
|
await api.post(endpoints.detect.media(selectedMedia.id))
|
||||||
|
} catch {
|
||||||
|
// banner stays visible briefly; sidebar SSE refresh will pick up results
|
||||||
|
} finally {
|
||||||
|
setAiProgress(1)
|
||||||
|
aiCloseTimerRef.current = window.setTimeout(() => {
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
setAiDetecting(false)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}, [selectedMedia, aiDetecting])
|
||||||
|
|
||||||
|
// Clear any pending AI-banner close timer on unmount.
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (aiCloseTimerRef.current != null) {
|
||||||
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = () => { videoPlayerRef.current?.toggle() }
|
||||||
|
const stepFrames = (n: number) => { videoPlayerRef.current?.frameStep(n) }
|
||||||
|
const seekRel = (sec: number) => {
|
||||||
|
const p = videoPlayerRef.current
|
||||||
|
if (!p) return
|
||||||
|
p.seek(Math.max(0, Math.min(p.getDuration(), p.getCurrentTime() + sec)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVolumeChange = (v: number) => {
|
||||||
|
setVolume(v)
|
||||||
|
videoPlayerRef.current?.setVolume(v)
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
// VideoPlayer.toggleMute() fires onMutedChange, which updates `muted` —
|
||||||
|
// don't flip parent state independently or the two desync (e.g. M-key
|
||||||
|
// shortcut already routed via onMutedChange).
|
||||||
|
videoPlayerRef.current?.toggleMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims = (() => {
|
||||||
|
const v = videoPlayerRef.current?.getVideoElement()
|
||||||
|
if (!v || !v.videoWidth) return null
|
||||||
|
return { w: v.videoWidth, h: v.videoHeight }
|
||||||
|
})()
|
||||||
|
const fps = videoPlayerRef.current?.getFrameRate() ?? 30
|
||||||
|
const currentFrame = isVideo ? Math.floor(currentTime * fps) : 0
|
||||||
|
const totalFrames = isVideo ? Math.floor(duration * fps) : 0
|
||||||
|
|
||||||
|
const detectionsLabel = `${detections.length} det${detections.length !== 1 ? 's' : ''}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Left panel */}
|
{/* LEFT SIDEBAR */}
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
|
||||||
<MediaList
|
<MediaList
|
||||||
selectedMedia={selectedMedia}
|
selectedMedia={selectedMedia}
|
||||||
onSelect={setSelectedMedia}
|
onSelect={setSelectedMedia}
|
||||||
@@ -219,41 +335,46 @@ export default function AnnotationsPage() {
|
|||||||
onPhotoModeChange={setPhotoMode}
|
onPhotoModeChange={setPhotoMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{/* CENTER */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
|
||||||
{/* Center - video/canvas */}
|
{/* Canvas top bar */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.canvas')}</span>
|
||||||
{selectedMedia && (
|
{selectedMedia && (
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
<>
|
||||||
<button
|
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
|
||||||
onClick={handleSave}
|
{dims && (
|
||||||
disabled={!detections.length}
|
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
|
||||||
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
{dims.w}×{dims.h} · {fps} FPS
|
||||||
>
|
</span>
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => canvasRef.current?.deleteSelected()}
|
|
||||||
disabled={!detections.length}
|
|
||||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => canvasRef.current?.deleteAll()}
|
|
||||||
disabled={!detections.length}
|
|
||||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Remove All
|
|
||||||
</button>
|
|
||||||
<span className="text-az-muted text-[10px]">{detections.length} detection{detections.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="micro">{t('annotations.zoom')}</span>
|
||||||
|
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
|
||||||
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="micro">{t('annotations.cursor')}</span>
|
||||||
|
<span className="mono text-[11px] text-text-primary">
|
||||||
|
{cursor ? `${cursor.x.toFixed(3)}, ${cursor.y.toFixed(3)}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{detectionsLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
{selectedMedia && isVideo && (
|
{selectedMedia && isVideo && (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
ref={videoPlayerRef}
|
ref={videoPlayerRef}
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
|
onPlayingChange={setIsPlaying}
|
||||||
|
onDurationChange={setDuration}
|
||||||
|
onMutedChange={setMuted}
|
||||||
>
|
>
|
||||||
<CanvasEditor
|
<CanvasEditor
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -264,6 +385,8 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
)}
|
)}
|
||||||
@@ -277,18 +400,178 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!selectedMedia && (
|
{!selectedMedia && (
|
||||||
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
|
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
|
||||||
Select a media file to start
|
{t('annotations.selectMedia')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Detection floating banner */}
|
||||||
|
{aiDetecting && (
|
||||||
|
<div className="absolute top-6 right-6 ai-banner px-3 py-2 w-72">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span className="micro text-accent-cyan">{t('annotations.detectInProgress')}</span>
|
||||||
|
<span className="ml-auto mono text-[10px] text-text-muted">{aiElapsed.toFixed(1)}s</span>
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10px] space-y-0.5 text-text-secondary">
|
||||||
|
{aiLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-[2px] bg-black/40 overflow-hidden">
|
||||||
|
<div style={{ height: '100%', width: `${aiProgress * 100}%`, background: 'var(--accent-cyan)' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* Scrubber + Controls */}
|
||||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{selectedMedia && isVideo && (
|
||||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
<div className="border-t border-border-hair bg-surface-1 shrink-0">
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<Scrubber
|
||||||
|
current={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
marks={scrubberMarks}
|
||||||
|
onSeek={t => { videoPlayerRef.current?.seek(t); setCurrentTime(t) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-1.5 min-w-0 whitespace-nowrap overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.previousMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.back5s')} onClick={() => seekRel(-5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ibtn"
|
||||||
|
title={isPlaying ? t('annotations.pause') : t('annotations.play')}
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: isPlaying ? 'rgba(255,157,61,0.12)' : 'transparent',
|
||||||
|
color: isPlaying ? 'var(--accent-amber)' : undefined,
|
||||||
|
borderColor: isPlaying ? 'var(--accent-amber)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying
|
||||||
|
? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||||
|
: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.forward5s')} onClick={() => seekRel(5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.nextMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="micro">{t('annotations.frameStep')}</span>
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
{FRAME_STEPS.map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => stepFrames(n)}
|
||||||
|
className="ibtn mono"
|
||||||
|
style={{ width: 30, height: 28, fontSize: 10, border: 0, background: 'transparent', letterSpacing: 0 }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||||
|
{t('annotations.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteSelected()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||||
|
{t('annotations.delete')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteAll()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
title={t('annotations.deleteAllTitle')}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
|
||||||
|
{t('annotations.deleteAll')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAiDetect}
|
||||||
|
disabled={!selectedMedia || aiDetecting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
|
||||||
|
{t('annotations.detect')}
|
||||||
|
<span className="ml-1 mono opacity-70" style={{ fontSize: 9 }}>[R]</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28 }} title={t('annotations.mute')} onClick={toggleMute}>
|
||||||
|
{muted
|
||||||
|
? <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.95 8.95 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.17v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||||
|
: <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="vol"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round(volume * 100)}
|
||||||
|
onChange={e => onVolumeChange(Number(e.target.value) / 100)}
|
||||||
|
/>
|
||||||
|
<span className="mono text-[10px] text-text-muted" style={{ width: 24 }}>{Math.round(volume * 100)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="px-4 h-7 flex items-center border-t border-border-hair bg-surface-0">
|
||||||
|
<span className="mono text-[11px] text-text-primary">{formatTime(currentTime, true)}</span>
|
||||||
|
<span className="mono text-[11px] mx-1.5 text-text-muted">/</span>
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{formatTime(duration, true)}</span>
|
||||||
|
<span className="mx-3 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="micro">{t('annotations.frame')}</span>
|
||||||
|
<span className="mono text-[11px] ml-1.5 text-text-primary">{currentFrame} / {totalFrames}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo-only controls row (save/delete/AI detect) */}
|
||||||
|
{selectedMedia && !isVideo && (
|
||||||
|
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
|
||||||
|
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
|
||||||
|
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
|
||||||
|
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
|
||||||
|
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT SIDEBAR */}
|
||||||
|
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
|
||||||
<AnnotationsSidebar
|
<AnnotationsSidebar
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaDownload } from 'react-icons/fa'
|
import { FaDownload } from 'react-icons/fa'
|
||||||
import { api, createSSE, endpoints } from '../../api'
|
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'
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +14,46 @@ interface Props {
|
|||||||
onDownload?: (ann: 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) {
|
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media) return
|
if (!media) return
|
||||||
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
})
|
})
|
||||||
}, [media, onAnnotationsUpdate])
|
}, [media, onAnnotationsUpdate])
|
||||||
|
|
||||||
const handleDetect = async () => {
|
const totals = useMemo(() => ({
|
||||||
if (!media) return
|
total: annotations.length,
|
||||||
setDetecting(true)
|
empty: annotations.filter(a => a.detections.length === 0).length,
|
||||||
setDetectLog(['Starting AI detection...'])
|
}), [annotations])
|
||||||
try {
|
|
||||||
await api.post(endpoints.detect.media(media.id))
|
|
||||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
|
||||||
} catch (e: any) {
|
|
||||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowGradient = (ann: AnnotationListItem) => {
|
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
|
||||||
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(', ')})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-surface-1">
|
||||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
|
||||||
onClick={handleDetect}
|
<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>
|
||||||
disabled={!media}
|
|
||||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('annotations.detect')}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
|
||||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
<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>
|
||||||
disabled={!selectedAnnotation}
|
|
||||||
title="Download annotation"
|
|
||||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<FaDownload size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
|
||||||
{annotations.map(ann => (
|
<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
|
<div
|
||||||
key={ann.id}
|
key={ann.id}
|
||||||
onClick={() => onSelect(ann)}
|
onClick={() => onSelect(ann)}
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
|
className={`ann-row${isSelected ? ' active' : ''}`}
|
||||||
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
|
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
|
||||||
}`}
|
|
||||||
style={{ background: getRowGradient(ann) }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
|
||||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
{ann.time || '—'}
|
||||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
</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>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{annotations.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{detecting && (
|
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
<span className="micro">{t('annotations.summary')}</span>
|
||||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
<span className="mono text-[10px] text-text-muted">
|
||||||
<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">
|
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
|
||||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
{classDist.length > 0 && (
|
||||||
Close
|
<>
|
||||||
</button>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
|||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import { MediaType } from '../../types'
|
import { MediaType } from '../../types'
|
||||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } 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 {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
@@ -12,6 +13,8 @@ interface Props {
|
|||||||
selectedClassNum: number
|
selectedClassNum: number
|
||||||
currentTime: number
|
currentTime: number
|
||||||
annotations: AnnotationListItem[]
|
annotations: AnnotationListItem[]
|
||||||
|
onZoomChange?: (zoom: number) => void
|
||||||
|
onCursorChange?: (nx: number, ny: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasEditorHandle {
|
export interface CanvasEditorHandle {
|
||||||
@@ -28,28 +31,60 @@ interface DragState {
|
|||||||
handle?: string
|
handle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelChip {
|
||||||
|
leftPct: number
|
||||||
|
topPct: number
|
||||||
|
color: string
|
||||||
|
name: string
|
||||||
|
conf: number
|
||||||
|
combatReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 6
|
const HANDLE_SIZE = 6
|
||||||
const MIN_BOX_SIZE = 12
|
const MIN_BOX_SIZE = 12
|
||||||
|
|
||||||
const AFFILIATION_COLORS: Record<number, string> = {
|
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||||
0: '#FFD700',
|
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||||
1: '#228be6',
|
|
||||||
2: '#fa5252',
|
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(
|
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const imgRef = useRef<HTMLImageElement | null>(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 [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | 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 [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||||
|
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const loadImage = useCallback(() => {
|
const loadImage = useCallback(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Use natural size based on container; no image load
|
|
||||||
imgRef.current = null
|
imgRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [isVideo])
|
}, [isVideo])
|
||||||
|
|
||||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||||
x: nx * imgSize.w * zoom + pan.x,
|
|
||||||
y: ny * imgSize.h * zoom + pan.y,
|
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||||
}), [imgSize, zoom, pan])
|
useEffect(() => () => {
|
||||||
|
if (cursorRafRef.current != null) {
|
||||||
|
cancelAnimationFrame(cursorRafRef.current)
|
||||||
|
cursorRafRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
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))),
|
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||||
}), [imgSize, zoom, pan])
|
}), [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 draw = useCallback(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const ctx = canvas?.getContext('2d')
|
const ctx = canvas?.getContext('2d')
|
||||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const timeWindowDets = getTimeWindowDetections()
|
const timeWindowDets = getTimeWindowDetections()
|
||||||
const allDets = [...detections, ...timeWindowDets]
|
const allDets = [...detections, ...timeWindowDets]
|
||||||
|
const chips: LabelChip[] = []
|
||||||
|
|
||||||
allDets.forEach((det, i) => {
|
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 cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||||
const w = det.width * imgSize.w * zoom
|
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.strokeRect(cx, cy, w, h)
|
||||||
|
|
||||||
ctx.fillStyle = color
|
ctx.fillStyle = color
|
||||||
ctx.globalAlpha = 0.1
|
ctx.globalAlpha = 0.06
|
||||||
ctx.fillRect(cx, cy, w, h)
|
ctx.fillRect(cx, cy, w, h)
|
||||||
ctx.globalAlpha = 1
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
const name = det.label || getClassNameFallback(det.classNum)
|
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
const legLen = 8
|
||||||
const label = `${name}${modeSuffix}${confSuffix}`
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
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'
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||||
ctx.fill()
|
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) {
|
if (isSelected) {
|
||||||
const handles = getHandles(cx, cy, w, h)
|
const handles = getHandles(cx, cy, w, h)
|
||||||
handles.forEach(hp => {
|
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.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)
|
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (drawRect) {
|
if (drawRect) {
|
||||||
ctx.strokeStyle = '#fd7e14'
|
ctx.strokeStyle = '#FF9D3D'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.setLineDash([4, 4])
|
ctx.setLineDash([4, 4])
|
||||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
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(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(draw)
|
const id = requestAnimationFrame(draw)
|
||||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [draw])
|
}, [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) => {
|
const hitTest = (cx: number, cy: number) => {
|
||||||
for (let i = detections.length - 1; i >= 0; i--) {
|
for (let i = detections.length - 1; i >= 0; i--) {
|
||||||
const d = detections[i]
|
const d = detections[i]
|
||||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!dragState) return
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect()
|
const rect = canvasRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
const mx = e.clientX - rect.left
|
const mx = e.clientX - rect.left
|
||||||
const my = e.clientY - rect.top
|
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') {
|
if (dragState.type === 'draw') {
|
||||||
setDrawRect({
|
setDrawRect({
|
||||||
x: Math.min(dragState.startX, mx),
|
x: Math.min(dragState.startX, mx),
|
||||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onWheel={handleWheel}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
const debouncedFilter = useDebounce(filter, 300)
|
const debouncedFilter = useDebounce(filter, 300)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fetchMedia = useCallback(async () => {
|
const fetchMedia = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ pageSize: '1000' })
|
const params = new URLSearchParams({ pageSize: '1000' })
|
||||||
@@ -139,25 +140,20 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps({
|
{...getRootProps({
|
||||||
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
|
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* Dropzone hidden input */}
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="p-2 border-b border-az-border flex gap-1">
|
|
||||||
<input
|
{/* Hidden file inputs */}
|
||||||
value={filter}
|
|
||||||
onChange={e => setFilter(e.target.value)}
|
|
||||||
placeholder={t('annotations.mediaList')}
|
|
||||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 pt-2 pb-2 flex gap-1">
|
|
||||||
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
|
|
||||||
Open File
|
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -166,14 +162,6 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
|
||||||
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
|
|
||||||
>
|
|
||||||
Open Folder
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
ref={folderInputRef}
|
ref={folderInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -184,25 +172,94 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
directory=""
|
directory=""
|
||||||
onChange={handleFolderInput}
|
onChange={handleFolderInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.mediaList')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex items-center gap-1">
|
||||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
{/* Upload file button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('annotations.upload')}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Open folder button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title="Open Folder"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter input row */}
|
||||||
|
<div className="px-3 py-2 border-b border-border-hair shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="inp w-full pl-7"
|
||||||
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
placeholder={t('annotations.filterByName')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{filtered.map(m => {
|
||||||
|
const isActive = selectedMedia?.id === m.id
|
||||||
|
const isVideo = m.mediaType === MediaType.Video
|
||||||
|
const hasDuration = !!m.duration
|
||||||
|
const durationColor = isActive
|
||||||
|
? 'text-accent-amber'
|
||||||
|
: hasDuration
|
||||||
|
? 'text-text-secondary'
|
||||||
|
: 'text-text-muted'
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => handleSelect(m)}
|
onClick={() => handleSelect(m)}
|
||||||
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs flex items-center gap-1.5 ${
|
className={`media-row${isActive ? ' active' : ''}`}
|
||||||
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
|
|
||||||
} ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`}
|
|
||||||
>
|
>
|
||||||
<span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
{isVideo
|
||||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
? <span className="chip-video">VIDEO</span>
|
||||||
|
: <span className="chip-photo">PHOTO</span>
|
||||||
|
}
|
||||||
|
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<span className={`mono text-[11px] ${durationColor}`}>
|
||||||
|
{m.duration ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1">{m.name}</span>
|
|
||||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
title={t('annotations.deleteMedia')}
|
title={t('annotations.deleteMedia')}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface ScrubberMark {
|
||||||
|
time: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
current: number
|
||||||
|
duration: number
|
||||||
|
marks: ScrubberMark[]
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_PERCENTS = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
export default function Scrubber({ current, duration, marks, onSeek }: Props) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const safeDuration = duration > 0 ? duration : 1
|
||||||
|
const pct = Math.max(0, Math.min(100, (current / safeDuration) * 100))
|
||||||
|
|
||||||
|
const seekFromClientX = useCallback((clientX: number) => {
|
||||||
|
const el = trackRef.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const x = Math.max(0, Math.min(rect.width, clientX - rect.left))
|
||||||
|
onSeek((x / rect.width) * safeDuration)
|
||||||
|
}, [onSeek, safeDuration])
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
seekFromClientX(e.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
const move = (e: MouseEvent) => seekFromClientX(e.clientX)
|
||||||
|
const up = () => setDragging(false)
|
||||||
|
window.addEventListener('mousemove', move)
|
||||||
|
window.addEventListener('mouseup', up)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', move)
|
||||||
|
window.removeEventListener('mouseup', up)
|
||||||
|
}
|
||||||
|
}, [dragging, seekFromClientX])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={trackRef} className="scrub" onMouseDown={handleMouseDown}>
|
||||||
|
<div className="fill" style={{ width: `${pct}%` }} />
|
||||||
|
{TICK_PERCENTS.map(p => (
|
||||||
|
<div key={p} className="tick" style={{ left: `${p}%` }} />
|
||||||
|
))}
|
||||||
|
{marks.map((m, i) => {
|
||||||
|
const mpct = Math.max(0, Math.min(100, (m.time / safeDuration) * 100))
|
||||||
|
return <div key={i} className="mark" style={{ left: `${mpct}%`, background: m.color }} />
|
||||||
|
})}
|
||||||
|
<div className="head" style={{ left: `${pct}%` }} />
|
||||||
|
<div className="head-knob" style={{ left: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +1,52 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
|
||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import type { Media } from '../../types'
|
import type { Media } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
onTimeUpdate: (time: number) => void
|
onTimeUpdate: (time: number) => void
|
||||||
|
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
|
||||||
|
onPlayingChange?: (playing: boolean) => void
|
||||||
|
/** Fires when the <video> reports a valid duration. */
|
||||||
|
onDurationChange?: (duration: number) => void
|
||||||
|
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
|
||||||
|
onMutedChange?: (muted: boolean) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
|
|
||||||
const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
|
|
||||||
|
|
||||||
export interface VideoPlayerHandle {
|
export interface VideoPlayerHandle {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
getVideoElement: () => HTMLVideoElement | null
|
getVideoElement: () => HTMLVideoElement | null
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
toggle: () => void
|
||||||
|
isPlaying: () => boolean
|
||||||
|
frameStep: (deltaFrames: number) => void
|
||||||
|
getDuration: () => number
|
||||||
|
getCurrentTime: () => number
|
||||||
|
getFrameRate: () => number
|
||||||
|
getCurrentFrame: () => number
|
||||||
|
getTotalFrames: () => number
|
||||||
|
getVolume: () => number
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
isMuted: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
const FPS = 30
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||||
seek(seconds: number) {
|
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
|
||||||
if (videoRef.current) {
|
}, ref) {
|
||||||
videoRef.current.currentTime = seconds
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getVideoElement() {
|
|
||||||
return videoRef.current
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
|
||||||
const [duration, setDuration] = useState(0)
|
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
|
|
||||||
|
const notifyMuted = useCallback((m: boolean) => {
|
||||||
|
setMuted(m)
|
||||||
|
onMutedChange?.(m)
|
||||||
|
}, [onMutedChange])
|
||||||
|
|
||||||
const videoUrl = media.path.startsWith('blob:')
|
const videoUrl = media.path.startsWith('blob:')
|
||||||
? media.path
|
? media.path
|
||||||
: endpoints.annotations.mediaFile(media.id)
|
: endpoints.annotations.mediaFile(media.id)
|
||||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
const stepFrames = useCallback((count: number) => {
|
const stepFrames = useCallback((count: number) => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
const fps = 30
|
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
|
||||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
if (v.paused) { v.play(); setPlaying(true) }
|
if (v.paused) v.play().catch(() => {})
|
||||||
else { v.pause(); setPlaying(false) }
|
else v.pause()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
useImperativeHandle(ref, () => ({
|
||||||
|
seek(seconds: number) {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (v) v.currentTime = seconds
|
||||||
v.pause()
|
},
|
||||||
v.currentTime = 0
|
getVideoElement() { return videoRef.current },
|
||||||
setPlaying(false)
|
play() { videoRef.current?.play().catch(() => {}) },
|
||||||
}, [])
|
pause() { videoRef.current?.pause() },
|
||||||
|
toggle() { togglePlay() },
|
||||||
|
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
|
||||||
|
frameStep(delta) { stepFrames(delta) },
|
||||||
|
getDuration() { return videoRef.current?.duration ?? 0 },
|
||||||
|
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
|
||||||
|
getFrameRate() { return FPS },
|
||||||
|
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
|
||||||
|
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
|
||||||
|
getVolume() { return videoRef.current?.volume ?? 1 },
|
||||||
|
setVolume(v) {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.volume = Math.max(0, Math.min(1, v))
|
||||||
|
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.muted = !el.muted
|
||||||
|
notifyMuted(el.muted)
|
||||||
|
},
|
||||||
|
isMuted() { return !!videoRef.current?.muted },
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
case ' ': e.preventDefault(); togglePlay(); break
|
case ' ': e.preventDefault(); togglePlay(); break
|
||||||
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
||||||
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
||||||
case 'm': case 'M': setMuted(m => !m); break
|
case 'm': case 'M': {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [togglePlay, stepFrames])
|
}, [togglePlay, stepFrames])
|
||||||
|
|
||||||
const formatTime = (s: number) => {
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const sec = Math.floor(s % 60)
|
|
||||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
|
||||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
{error && (
|
||||||
|
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
|
||||||
|
)}
|
||||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
controls={false}
|
controls={false}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
|
||||||
const t = (e.target as HTMLVideoElement).currentTime
|
onPlay={() => onPlayingChange?.(true)}
|
||||||
setCurrentTime(t)
|
onPause={() => onPlayingChange?.(false)}
|
||||||
onTimeUpdate(t)
|
onDurationChange={e => {
|
||||||
}}
|
const d = (e.target as HTMLVideoElement).duration
|
||||||
onLoadedMetadata={e => {
|
if (Number.isFinite(d)) onDurationChange?.(d)
|
||||||
setDuration((e.target as HTMLVideoElement).duration)
|
|
||||||
setError(null)
|
|
||||||
}}
|
}}
|
||||||
|
onLoadedMetadata={() => setError(null)}
|
||||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||||
/>
|
/>
|
||||||
{children && <div className="absolute inset-0">{children}</div>}
|
{children && <div className="absolute inset-0">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress row: time | slider | remaining */}
|
|
||||||
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
|
|
||||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={duration || 1}
|
|
||||||
step={0.01}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={e => {
|
|
||||||
const v = Number(e.target.value)
|
|
||||||
setCurrentTime(v)
|
|
||||||
if (videoRef.current) videoRef.current.currentTime = v
|
|
||||||
}}
|
|
||||||
className="flex-1 accent-az-orange h-1 cursor-pointer"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons row */}
|
|
||||||
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
|
|
||||||
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStepBackward size={14} />
|
|
||||||
</button>
|
|
||||||
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
|
|
||||||
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStepForward size={14} />
|
|
||||||
</button>
|
|
||||||
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStop size={14} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
|
|
||||||
{[1, 5, 10, 30, 60].map(n => (
|
|
||||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
|
|
||||||
-{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
{[1, 5, 10, 30, 60].map(n => (
|
|
||||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
|
|
||||||
+{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
|
|
||||||
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
|
|
||||||
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Annotation time helpers — shared between AnnotationsPage and CanvasEditor.
|
||||||
|
* Annotation `time` is the backend's "HH:MM:SS.mmm" tick representation; this
|
||||||
|
* module owns the conversion to/from seconds + display formatting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseAnnotationTime(t: string | null | undefined): number | null {
|
||||||
|
if (!t) return null
|
||||||
|
const parts = t.split(':').map(Number)
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
if (parts.some(p => !Number.isFinite(p))) return null
|
||||||
|
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(seconds: number, withMs = false): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const base = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
if (!withMs) return base
|
||||||
|
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||||
|
return `${base}.${String(ms).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTicks(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
||||||
|
}
|
||||||
+32
-3
@@ -85,18 +85,47 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations",
|
"title": "Annotations",
|
||||||
"mediaList": "Media",
|
"mediaList": "Media Files",
|
||||||
|
"filterByName": "filter by name…",
|
||||||
"upload": "Upload Files",
|
"upload": "Upload Files",
|
||||||
"deleteMedia": "Delete media?",
|
"deleteMedia": "Delete media?",
|
||||||
"detect": "AI Detect",
|
"detect": "AI Detect",
|
||||||
|
"detectInProgress": "AI DETECTION IN PROGRESS",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteAll": "Delete All",
|
"deleteAll": "Delete All",
|
||||||
|
"deleteAllTitle": "Delete all on frame",
|
||||||
"classes": "Detection Classes",
|
"classes": "Detection Classes",
|
||||||
"photoMode": "Photo Mode",
|
"photoMode": "PhotoMode",
|
||||||
"regular": "Regular",
|
"regular": "Regular",
|
||||||
"winter": "Winter",
|
"winter": "Winter",
|
||||||
"night": "Night"
|
"night": "Night",
|
||||||
|
"colName": "NAME",
|
||||||
|
"colKey": "KEY",
|
||||||
|
"colNum": "#",
|
||||||
|
"colTime": "TIME",
|
||||||
|
"colClass": "CLASS",
|
||||||
|
"colConf": "CONF",
|
||||||
|
"canvas": "Canvas",
|
||||||
|
"zoom": "ZOOM",
|
||||||
|
"cursor": "CURSOR",
|
||||||
|
"frameStep": "FRAME STEP",
|
||||||
|
"frame": "FRAME",
|
||||||
|
"summary": "SUMMARY",
|
||||||
|
"emptyFrame": "empty frame",
|
||||||
|
"filter": "Filter",
|
||||||
|
"sort": "Sort",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"previousMedia": "Previous media",
|
||||||
|
"nextMedia": "Next media",
|
||||||
|
"back5s": "Back 5s",
|
||||||
|
"forward5s": "Forward 5s",
|
||||||
|
"mute": "Mute",
|
||||||
|
"selectMedia": "Select a media file to start",
|
||||||
|
"annCount_one": "{{count}} ann",
|
||||||
|
"annCount_other": "{{count}} ann",
|
||||||
|
"emptyCount": "{{count}} empty"
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Dataset Explorer",
|
"title": "Dataset Explorer",
|
||||||
|
|||||||
+33
-2
@@ -85,18 +85,49 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Анотації",
|
"title": "Анотації",
|
||||||
"mediaList": "Медіа",
|
"mediaList": "Медіа файли",
|
||||||
|
"filterByName": "фільтр за назвою…",
|
||||||
"upload": "Завантажити файли",
|
"upload": "Завантажити файли",
|
||||||
"deleteMedia": "Видалити медіа?",
|
"deleteMedia": "Видалити медіа?",
|
||||||
"detect": "AI Розпізнавання",
|
"detect": "AI Розпізнавання",
|
||||||
|
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"deleteAll": "Видалити все",
|
"deleteAll": "Видалити все",
|
||||||
|
"deleteAllTitle": "Видалити все на кадрі",
|
||||||
"classes": "Класи детекцій",
|
"classes": "Класи детекцій",
|
||||||
"photoMode": "Режим фото",
|
"photoMode": "Режим фото",
|
||||||
"regular": "Звичайний",
|
"regular": "Звичайний",
|
||||||
"winter": "Зимовий",
|
"winter": "Зимовий",
|
||||||
"night": "Нічний"
|
"night": "Нічний",
|
||||||
|
"colName": "НАЗВА",
|
||||||
|
"colKey": "КЛВ",
|
||||||
|
"colNum": "№",
|
||||||
|
"colTime": "ЧАС",
|
||||||
|
"colClass": "КЛАС",
|
||||||
|
"colConf": "ВПЕВ",
|
||||||
|
"canvas": "Канва",
|
||||||
|
"zoom": "ЗУМ",
|
||||||
|
"cursor": "КУРСОР",
|
||||||
|
"frameStep": "КРОК КАДРУ",
|
||||||
|
"frame": "КАДР",
|
||||||
|
"summary": "ПІДСУМОК",
|
||||||
|
"emptyFrame": "порожній кадр",
|
||||||
|
"filter": "Фільтр",
|
||||||
|
"sort": "Сортувати",
|
||||||
|
"play": "Програти",
|
||||||
|
"pause": "Пауза",
|
||||||
|
"previousMedia": "Попереднє медіа",
|
||||||
|
"nextMedia": "Наступне медіа",
|
||||||
|
"back5s": "Назад 5с",
|
||||||
|
"forward5s": "Вперед 5с",
|
||||||
|
"mute": "Без звуку",
|
||||||
|
"selectMedia": "Оберіть файл медіа щоб почати",
|
||||||
|
"annCount_one": "{{count}} анот.",
|
||||||
|
"annCount_few": "{{count}} анот.",
|
||||||
|
"annCount_many": "{{count}} анот.",
|
||||||
|
"annCount_other": "{{count}} анот.",
|
||||||
|
"emptyCount": "{{count}} порожн."
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Датасет",
|
"title": "Датасет",
|
||||||
|
|||||||
+179
@@ -444,3 +444,182 @@ select.inp {
|
|||||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||||
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
ANNOTATIONS PAGE — v2 surfaces
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* Splitter affordance between resizable panes */
|
||||||
|
.split { width: 4px; cursor: col-resize; background: transparent; position: relative; }
|
||||||
|
.split::after {
|
||||||
|
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
|
||||||
|
background: var(--border-hair);
|
||||||
|
}
|
||||||
|
.split:hover::after { background: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Media list row (264px left aside) */
|
||||||
|
.media-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px; padding: 0 12px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.media-row:hover { background: var(--surface-2); }
|
||||||
|
.media-row.active { background: var(--surface-2); }
|
||||||
|
.media-row.active::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type chips inside media rows */
|
||||||
|
.chip-photo {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-cyan); border: 1px solid rgba(54,214,197,0.45);
|
||||||
|
background: rgba(54,214,197,0.06);
|
||||||
|
}
|
||||||
|
.chip-video {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-amber); border: 1px solid rgba(255,157,61,0.45);
|
||||||
|
background: rgba(255,157,61,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detection class row */
|
||||||
|
.class-row {
|
||||||
|
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
|
||||||
|
align-items: center; height: 28px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.class-row:hover { background: var(--surface-2); }
|
||||||
|
.class-row.active { background: var(--surface-2); }
|
||||||
|
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Keycap chip */
|
||||||
|
.kbd {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 16px; padding: 0;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation row in right sidebar (gradient stripe via --row-grad) */
|
||||||
|
.ann-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
}
|
||||||
|
.ann-row::after {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
|
||||||
|
background-image: var(--row-grad, none);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ann-row > * { position: relative; z-index: 1; }
|
||||||
|
.ann-row:hover { background-color: var(--surface-2); }
|
||||||
|
.ann-row.active { background-color: var(--surface-2); }
|
||||||
|
|
||||||
|
/* Faux terrain wash behind canvas */
|
||||||
|
.terrain {
|
||||||
|
background-color: #11181B;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
|
||||||
|
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
|
||||||
|
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, auto, 48px 48px, 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating AI Detection banner over canvas */
|
||||||
|
.ai-banner {
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background: rgba(10,13,16,0.78);
|
||||||
|
border: 1px solid rgba(54,214,197,0.4);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounding-box label chip (DOM overlay on canvas) */
|
||||||
|
.bbox-label {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 22px; padding: 0 8px;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(10,13,16,0.92);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-hair);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Selection handles on bounding boxes */
|
||||||
|
.handle {
|
||||||
|
position: absolute; width: 6px; height: 6px;
|
||||||
|
background: var(--accent-amber); border: 1px solid #0A0D10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrubber (timeline with annotation marks) */
|
||||||
|
.scrub {
|
||||||
|
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
|
||||||
|
border-radius: 2px; position: relative; cursor: pointer;
|
||||||
|
}
|
||||||
|
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); pointer-events: none; }
|
||||||
|
.scrub .head {
|
||||||
|
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
|
||||||
|
transform: translate(-50%, -50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .head-knob {
|
||||||
|
position: absolute; top: 50%; width: 12px; height: 12px;
|
||||||
|
background: var(--accent-amber);
|
||||||
|
border: 2px solid var(--surface-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
|
||||||
|
z-index: 2;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.scrub .head-knob:active { cursor: grabbing; }
|
||||||
|
.scrub .tick {
|
||||||
|
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
|
||||||
|
transform: translateY(-50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .mark {
|
||||||
|
position: absolute; top: -3px; width: 2px; height: 10px; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume slider (range input next to mute) */
|
||||||
|
.vol {
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.vol::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.vol::-moz-range-thumb {
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live pulse dot (cyan glow) — annotations LIVE indicators */
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 999px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
display: inline-block; flex: none;
|
||||||
|
}
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OWM_API_KEY?: string
|
readonly VITE_OWM_API_KEY?: string
|
||||||
readonly VITE_OWM_BASE_URL?: string
|
readonly VITE_OWM_BASE_URL?: string
|
||||||
readonly VITE_SATELLITE_TILE_URL?: string
|
readonly VITE_SATELLITE_TILE_URL?: string
|
||||||
|
/** Dev-only: when 'true', skip backend auth and inject a fake admin user. */
|
||||||
|
readonly VITE_DEV_AUTH_BYPASS?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user