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

Reskin to v2 surface/accent tokens + JetBrains Mono headings to match
_docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored
annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection
banner, multi-band gradient rows in the annotations sidebar, class-distribution
summary footer, and DOM-overlay bbox labels with affiliation icon + readiness
dot. Split VideoPlayer chrome out into the page-level controls row
(transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber
component; player events replace 200ms polling.

Other:
- Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV).
- Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash.
- Extract hexToRgba to src/class-colors and time helpers to
  src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar /
  AnnotationsPage).
- CanvasEditor: shallow-compare label chips before commit, NaN-guard
  annotation-time parser, cancel cursor RAF on unmount.
- AnnotationsPage: track AI-banner close timer, push initial volume to the
  <video> on media change, drop the duplicate parent muted state.
- Fixed sidebar widths (resize handles removed per design).
This commit is contained in:
Armen Rohalov
2026-05-28 02:28:10 +03:00
parent cfffb4bdd7
commit f754afff46
17 changed files with 1279 additions and 415 deletions
+15 -13
View File
@@ -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,19 +18,21 @@ export default function App() {
element={ element={
<ProtectedRoute> <ProtectedRoute>
<FlightProvider> <FlightProvider>
<div className="flex flex-col h-screen"> <SavedAnnotationsProvider>
<Header /> <div className="flex flex-col h-screen">
<div className="flex-1 overflow-hidden"> <Header />
<Routes> <div className="flex-1 overflow-hidden">
<Route path="/flights" element={<FlightsPage />} /> <Routes>
<Route path="/annotations" element={<AnnotationsPage />} /> <Route path="/flights" element={<FlightsPage />} />
<Route path="/dataset" element={<DatasetPage />} /> <Route path="/annotations" element={<AnnotationsPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/dataset" element={<DatasetPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/flights" replace />} /> <Route path="/settings" element={<SettingsPage />} />
</Routes> <Route path="*" element={<Navigate to="/flights" replace />} />
</Routes>
</div>
</div> </div>
</div> </SavedAnnotationsProvider>
</FlightProvider> </FlightProvider>
</ProtectedRoute> </ProtectedRoute>
} }
+22
View File
@@ -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
+8
View File
@@ -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})`
}
+1
View File
@@ -2,5 +2,6 @@ export {
getClassColor, getClassColor,
getPhotoModeSuffix, getPhotoModeSuffix,
getClassNameFallback, getClassNameFallback,
hexToRgba,
FALLBACK_CLASS_NAMES, FALLBACK_CLASS_NAMES,
} from './classColors' } from './classColors'
+60 -34
View File
@@ -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,43 +58,71 @@ 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)} </div>
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 className="flex gap-1"> {/* Column headers */}
{modes.map(m => ( <div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
<button <span className="micro">{t('annotations.colNum')}</span>
key={m.value} <span className="micro">{t('annotations.colName')}</span>
onClick={() => onPhotoModeChange(m.value)} <span className="micro">{t('annotations.colKey')}</span>
title={m.label} </div>
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`}`}
> {/* Class rows */}
{m.icon} <div>
</button> {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 => (
<button
key={m.value}
type="button"
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
onClick={() => onPhotoModeChange(m.value)}
>
{m.label}
</button>
))}
</div>
</div> </div>
</div> </div>
) )
+1
View File
@@ -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'
+355 -72
View File
@@ -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
if (aiCloseTimerRef.current != null) {
window.clearTimeout(aiCloseTimerRef.current)
aiCloseTimerRef.current = null
}
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])
function formatTicks(seconds: number): string { // Clear any pending AI-banner close timer on unmount.
const h = Math.floor(seconds / 3600) useEffect(() => () => {
const m = Math.floor((seconds % 3600) / 60) if (aiCloseTimerRef.current != null) {
const s = Math.floor(seconds % 60) window.clearTimeout(aiCloseTimerRef.current)
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000) aiCloseTimerRef.current = null
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}` }
}, [])
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,42 +335,62 @@ 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">
{selectedMedia && ( <div className="flex items-center gap-2">
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0"> <span className="sect-head">{t('annotations.canvas')}</span>
<button {selectedMedia && (
onClick={handleSave} <>
disabled={!detections.length} <span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
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 && (
> <span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
Save {dims.w}×{dims.h} · {fps} FPS
</button> </span>
<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">
{selectedMedia && isVideo && ( <span className="micro">{t('annotations.zoom')}</span>
<VideoPlayer <span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
ref={videoPlayerRef} <span className="mx-2 h-4 w-px bg-border-hair" />
media={selectedMedia} <span className="micro">{t('annotations.cursor')}</span>
onTimeUpdate={setCurrentTime} <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 && (
<VideoPlayer
ref={videoPlayerRef}
media={selectedMedia}
onTimeUpdate={setCurrentTime}
onPlayingChange={setIsPlaying}
onDurationChange={setDuration}
onMutedChange={setMuted}
>
<CanvasEditor
ref={canvasRef}
media={selectedMedia}
annotation={selectedAnnotation}
detections={detections}
onDetectionsChange={handleDetectionsChange}
selectedClassNum={selectedClassNum}
currentTime={currentTime}
annotations={annotations}
onZoomChange={setZoom}
onCursorChange={(x, y) => setCursor({ x, y })}
/>
</VideoPlayer>
)}
{selectedMedia && !isVideo && (
<CanvasEditor <CanvasEditor
ref={canvasRef} ref={canvasRef}
media={selectedMedia} media={selectedMedia}
@@ -264,31 +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 })}
/> />
</VideoPlayer> )}
{!selectedMedia && (
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
{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>
{/* Scrubber + Controls */}
{selectedMedia && isVideo && (
<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 && ( {selectedMedia && !isVideo && (
<CanvasEditor <div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
ref={canvasRef} <button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
media={selectedMedia} <button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
annotation={selectedAnnotation} <button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
detections={detections} <span className="mx-1 h-5 w-px bg-border-hair" />
onDetectionsChange={handleDetectionsChange} <button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
selectedClassNum={selectedClassNum} <span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
currentTime={currentTime}
annotations={annotations}
/>
)}
{!selectedMedia && (
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
Select a media file to start
</div> </div>
)} )}
</div> </div>
{/* Right panel */} {/* RIGHT SIDEBAR */}
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" /> <div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
<AnnotationsSidebar <AnnotationsSidebar
media={selectedMedia} media={selectedMedia}
annotations={annotations} annotations={annotations}
+124 -68
View File
@@ -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>
<div <span className="micro">{t('annotations.colClass')}</span>
key={ann.id} <span className="micro">{t('annotations.colConf')}</span>
onClick={() => onSelect(ann)} </div>
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : '' <div className="flex-1 overflow-y-auto min-h-0">
}`} {annotations.map(ann => {
style={{ background: getRowGradient(ann) }} const isSelected = selectedAnnotation?.id === ann.id
> const isEmpty = ann.detections.length === 0
<div className="flex items-center justify-between"> const first = ann.detections[0]
<span className="text-az-text font-mono">{ann.time || ''}</span> const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span> const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
return (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`ann-row${isSelected ? ' active' : ''}`}
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
>
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
{ann.time || '—'}
</span>
{isEmpty
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
}
<div className="flex items-center gap-1.5">
{isSelected && !isEmpty && onDownload && (
<button
onClick={e => { e.stopPropagation(); onDownload(ann) }}
className="ibtn"
style={{ width: 18, height: 18 }}
title="Download annotation"
>
<FaDownload size={9} />
</button>
)}
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
</span>
</div>
</div> </div>
</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>
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
Close
</button>
</div>
</div> </div>
)} {classDist.length > 0 && (
<>
<div className="flex items-center gap-1 h-2">
{classDist.map(c => (
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
))}
</div>
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
{classDist.map(c => (
<span key={c.classNum} className="flex items-center gap-1">
<span style={{ color: c.color }}></span> {c.count}
</span>
))}
</div>
</>
)}
</div>
</div> </div>
) )
} }
+159 -62
View File
@@ -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>
) )
}) })
+113 -56
View File
@@ -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,70 +140,126 @@ 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} <input
onChange={e => setFilter(e.target.value)} ref={fileInputRef}
placeholder={t('annotations.mediaList')} type="file"
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none" multiple
/> className="hidden"
</div> onChange={e => {
<div className="px-2 pt-2 pb-2 flex gap-1"> if (e.target.files?.length) uploadFiles(e.target.files)
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110"> e.target.value = ''
Open File }}
<input />
type="file" <input
multiple ref={folderInputRef}
className="hidden" type="file"
onChange={e => { multiple
if (e.target.files?.length) uploadFiles(e.target.files) className="hidden"
e.target.value = '' // @ts-expect-error webkitdirectory is non-standard but widely supported
}} webkitdirectory=""
/> directory=""
</label> onChange={handleFolderInput}
<button />
type="button"
onClick={() => folderInputRef.current?.click()} {/* Header row */}
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110" <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">
Open Folder <span className="sect-head">{t('annotations.mediaList')}</span>
</button> <span className="mono text-[10px] text-text-muted">{filtered.length}</span>
<input </div>
ref={folderInputRef} <div className="flex items-center gap-1">
type="file" {/* Upload file button */}
multiple <button
className="hidden" type="button"
// @ts-expect-error webkitdirectory is non-standard but widely supported className="ibtn"
webkitdirectory="" style={{ width: 22, height: 22 }}
directory="" title={t('annotations.upload')}
onChange={handleFolderInput} onClick={() => fileInputRef.current?.click()}
/>
</div>
<div className="flex-1 overflow-y-auto">
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
<div
key={m.id}
onClick={() => handleSelect(m)}
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 ${
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'}`}> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{m.mediaType === MediaType.Video ? 'V' : 'P'} <path d="M12 5v14M5 12h14"/>
</span> </svg>
<span className="truncate flex-1">{m.name}</span> </button>
{m.duration && <span className="text-az-muted">{m.duration}</span>} {/* Open folder button */}
</div> <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> </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
key={m.id}
onClick={() => handleSelect(m)}
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
className={`media-row${isActive ? ' active' : ''}`}
>
{isVideo
? <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>
</div>
)
})}
</div>
<ConfirmDialog <ConfirmDialog
open={!!deleteId} open={!!deleteId}
title={t('annotations.deleteMedia')} title={t('annotations.deleteMedia')}
+63
View File
@@ -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>
)
}
+80 -105
View File
@@ -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, () => ({
const v = videoRef.current seek(seconds: number) {
if (!v) return const v = videoRef.current
v.pause() if (v) v.currentTime = seconds
v.currentTime = 0 },
setPlaying(false) getVideoElement() { return videoRef.current },
}, []) 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>
) )
}) })
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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); }
}
+2
View File
@@ -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 {