mirror of
https://github.com/azaion/ui.git
synced 2026-04-22 22:16:34 +00:00
Merge branch 'feat/annotations' into dev
This commit is contained in:
+2
-1
@@ -1,5 +1,6 @@
|
||||
.idea
|
||||
|
||||
.claude
|
||||
.superpowers
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+164
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-63
File diff suppressed because one or more lines are too long
Vendored
+13
-13
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AZAION</title>
|
||||
<script type="module" crossorigin src="/assets/index-o2ENlayJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--amdfC0Y.css">
|
||||
</head>
|
||||
<body class="bg-[#1e1e1e] text-[#adb5bd]">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AZAION</title>
|
||||
<script type="module" crossorigin src="/assets/index-B-KLvAXK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Du68yxJU.css">
|
||||
</head>
|
||||
<body class="bg-[#1e1e1e] text-[#adb5bd]">
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.4.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api } from '../api/client'
|
||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
|
||||
import type { DetectionClass } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -10,12 +13,25 @@ interface Props {
|
||||
onPhotoModeChange: (mode: number) => void
|
||||
}
|
||||
|
||||
const FALLBACK_CLASSES: DetectionClass[] = [0, 20, 40].flatMap(modeOffset =>
|
||||
FALLBACK_CLASS_NAMES.map((name, i) => ({
|
||||
id: i + modeOffset,
|
||||
name,
|
||||
shortName: name.slice(0, 3),
|
||||
color: getClassColor(i),
|
||||
maxSizeM: 10,
|
||||
photoMode: modeOffset,
|
||||
})),
|
||||
)
|
||||
|
||||
export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,27 +47,25 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [classes, photoMode, onSelect])
|
||||
|
||||
// Auto-select first class of current photoMode when mode changes or classes load
|
||||
useEffect(() => {
|
||||
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||
const currentIsInMode = modeClasses.some(c => c.id === selectedClassNum)
|
||||
if (!currentIsInMode && modeClasses.length > 0) {
|
||||
onSelect(modeClasses[0].id)
|
||||
}
|
||||
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||
|
||||
const modes = [
|
||||
{ value: 0, label: t('annotations.regular') },
|
||||
{ value: 20, label: t('annotations.winter') },
|
||||
{ value: 40, label: t('annotations.night') },
|
||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border-t border-az-border p-2">
|
||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{modes.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => onPhotoModeChange(m.value)}
|
||||
className={`text-xs px-2 py-0.5 rounded ${photoMode === m.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
@@ -60,13 +74,26 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
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: c.color }} />
|
||||
<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 className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
||||
<div className="flex gap-1">
|
||||
{modes.map(m => (
|
||||
<button
|
||||
key={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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { api } from '../../api/client'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
import CanvasEditor from './CanvasEditor'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||
|
||||
export default function AnnotationsPage() {
|
||||
@@ -17,17 +20,158 @@ export default function AnnotationsPage() {
|
||||
const [detections, setDetections] = useState<Detection[]>([])
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
const rightPanel = useResizablePanel(200, 150, 350)
|
||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDetections([])
|
||||
setSelectedAnnotation(null)
|
||||
setCurrentTime(0)
|
||||
}, [selectedMedia])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedMedia || !detections.length) return
|
||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||
const body = { mediaId: selectedMedia.id, time, detections }
|
||||
|
||||
if (!selectedMedia.path.startsWith('blob:')) {
|
||||
try {
|
||||
await api.post('/api/annotations/annotations', body)
|
||||
const res = await api.get<{ items: AnnotationListItem[] }>(
|
||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
||||
)
|
||||
setAnnotations(res.items)
|
||||
return
|
||||
} catch {
|
||||
// fall through to local save
|
||||
}
|
||||
}
|
||||
|
||||
const local: AnnotationListItem = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
mediaId: selectedMedia.id,
|
||||
time,
|
||||
createdDate: new Date().toISOString(),
|
||||
userId: 'local',
|
||||
source: AnnotationSource.Manual,
|
||||
status: AnnotationStatus.Created,
|
||||
isSplit: false,
|
||||
splitTile: null,
|
||||
detections: [...detections],
|
||||
}
|
||||
setAnnotations(prev => [...prev, local])
|
||||
}, [selectedMedia, detections, currentTime])
|
||||
|
||||
const handleDownload = useCallback(async (ann: AnnotationListItem) => {
|
||||
if (!selectedMedia) return
|
||||
|
||||
const txt = ann.detections
|
||||
.map(d => `${d.classNum} ${d.centerX.toFixed(6)} ${d.centerY.toFixed(6)} ${d.width.toFixed(6)} ${d.height.toFixed(6)}`)
|
||||
.join('\n')
|
||||
const stem = `annotation_${new Date().toISOString().replace(/[:.]/g, '-')}`
|
||||
|
||||
const txtBlob = new Blob([txt], { type: 'text/plain' })
|
||||
const txtUrl = URL.createObjectURL(txtBlob)
|
||||
const txtA = document.createElement('a')
|
||||
txtA.href = txtUrl
|
||||
txtA.download = `${stem}.txt`
|
||||
txtA.click()
|
||||
URL.revokeObjectURL(txtUrl)
|
||||
|
||||
// Build the image: video frame or image with rectangles drawn
|
||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||
let w = 0, h = 0
|
||||
const canvas = document.createElement('canvas')
|
||||
if (videoEl && videoEl.videoWidth) {
|
||||
w = videoEl.videoWidth
|
||||
h = videoEl.videoHeight
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.drawImage(videoEl, 0, 0, w, h)
|
||||
} else if (!selectedMedia.path) {
|
||||
return
|
||||
} else {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = selectedMedia.path.startsWith('blob:')
|
||||
? selectedMedia.path
|
||||
: `/api/annotations/media/${selectedMedia.id}/file`
|
||||
await new Promise(res => { img.onload = res; img.onerror = res })
|
||||
w = img.naturalWidth
|
||||
h = img.naturalHeight
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.drawImage(img, 0, 0, w, h)
|
||||
}
|
||||
|
||||
if (w && h) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.lineWidth = 2
|
||||
ctx.font = '14px sans-serif'
|
||||
const labelH = 17
|
||||
const padX = 4
|
||||
for (const d of ann.detections) {
|
||||
const bx = (d.centerX - d.width / 2) * w
|
||||
const by = (d.centerY - d.height / 2) * h
|
||||
const bw = d.width * w
|
||||
const bh = d.height * h
|
||||
const color = getClassColor(d.classNum)
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.strokeRect(bx, by, bw, bh)
|
||||
|
||||
const name = d.label || getClassNameFallback(d.classNum)
|
||||
const modeSuffix = getPhotoModeSuffix(d.classNum)
|
||||
const confSuffix = d.confidence < 0.995 ? ` ${(d.confidence * 100).toFixed(0)}%` : ''
|
||||
const label = `${name}${modeSuffix}${confSuffix}`
|
||||
|
||||
const metrics = ctx.measureText(label)
|
||||
const labelW = metrics.width + padX * 2
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(bx, Math.max(0, by - labelH), labelW, labelH)
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillText(label, bx + padX, Math.max(13, by - 4))
|
||||
}
|
||||
}
|
||||
canvas.toBlob(blob => {
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${stem}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, 'image/png')
|
||||
}
|
||||
}, [selectedMedia])
|
||||
|
||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||
setSelectedAnnotation(ann)
|
||||
setDetections(ann.detections)
|
||||
if (ann.time) {
|
||||
const parts = ann.time.split(':').map(Number)
|
||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||
videoPlayerRef.current?.seek(seconds)
|
||||
setCurrentTime(seconds)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDetectionsChange = useCallback((dets: Detection[]) => {
|
||||
setDetections(dets)
|
||||
}, [])
|
||||
|
||||
const isVideo = selectedMedia?.mediaType === 2
|
||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||
|
||||
function formatTicks(seconds: number): string {
|
||||
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')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
@@ -48,16 +192,54 @@ export default function AnnotationsPage() {
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Center - video/canvas */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{selectedMedia && (
|
||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!detections.length}
|
||||
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"
|
||||
>
|
||||
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>
|
||||
)}
|
||||
{selectedMedia && isVideo && (
|
||||
<VideoPlayer
|
||||
ref={videoPlayerRef}
|
||||
media={selectedMedia}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
selectedClassNum={selectedClassNum}
|
||||
/>
|
||||
>
|
||||
<CanvasEditor
|
||||
ref={canvasRef}
|
||||
media={selectedMedia}
|
||||
annotation={selectedAnnotation}
|
||||
detections={detections}
|
||||
onDetectionsChange={handleDetectionsChange}
|
||||
selectedClassNum={selectedClassNum}
|
||||
currentTime={currentTime}
|
||||
annotations={annotations}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
)}
|
||||
{selectedMedia && (
|
||||
{selectedMedia && !isVideo && (
|
||||
<CanvasEditor
|
||||
ref={canvasRef}
|
||||
media={selectedMedia}
|
||||
annotation={selectedAnnotation}
|
||||
detections={detections}
|
||||
@@ -83,6 +265,7 @@ export default function AnnotationsPage() {
|
||||
selectedAnnotation={selectedAnnotation}
|
||||
onSelect={handleAnnotationSelect}
|
||||
onAnnotationsUpdate={setAnnotations}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import { getClassColor } from './classColors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -10,9 +12,10 @@ interface Props {
|
||||
selectedAnnotation: AnnotationListItem | null
|
||||
onSelect: (ann: AnnotationListItem) => void
|
||||
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
|
||||
onDownload?: (ann: AnnotationListItem) => void
|
||||
}
|
||||
|
||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate }: Props) {
|
||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
||||
@@ -45,29 +48,32 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
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 `${d.label ? getClassColor(d.classNum) : '#888'}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
||||
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
||||
})
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`
|
||||
}
|
||||
|
||||
const classColors: Record<number, string> = {}
|
||||
const getClassColor = (classNum: number) => {
|
||||
if (classColors[classNum]) return classColors[classNum]
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#188021', '#800000', '#008000', '#000080']
|
||||
return colors[classNum % colors.length]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-2 border-b border-az-border flex items-center justify-between">
|
||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={!media}
|
||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('annotations.detect')}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={!media}
|
||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('annotations.detect')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
||||
disabled={!selectedAnnotation}
|
||||
title="Download annotation"
|
||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
||||
>
|
||||
<FaDownload size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
@@ -11,6 +13,12 @@ interface Props {
|
||||
annotations: AnnotationListItem[]
|
||||
}
|
||||
|
||||
export interface CanvasEditorHandle {
|
||||
deleteSelected: () => void
|
||||
deleteAll: () => void
|
||||
hasSelection: () => boolean
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
type: 'draw' | 'move' | 'resize'
|
||||
startX: number
|
||||
@@ -28,7 +36,10 @@ const AFFILIATION_COLORS: Record<number, string> = {
|
||||
2: '#fa5252',
|
||||
}
|
||||
|
||||
export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) {
|
||||
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
@@ -39,11 +50,35 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
deleteSelected() {
|
||||
if (selected.size === 0) return
|
||||
onDetectionsChange(detections.filter((_, i) => !selected.has(i)))
|
||||
setSelected(new Set())
|
||||
},
|
||||
deleteAll() {
|
||||
onDetectionsChange([])
|
||||
setSelected(new Set())
|
||||
},
|
||||
hasSelection() {
|
||||
return selected.size > 0
|
||||
},
|
||||
}), [selected, detections, onDetectionsChange])
|
||||
|
||||
const isVideo = media.mediaType === MediaType.Video
|
||||
|
||||
const loadImage = useCallback(() => {
|
||||
if (isVideo) {
|
||||
// Use natural size based on container; no image load
|
||||
imgRef.current = null
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
if (annotation) {
|
||||
if (annotation && !media.path.startsWith('blob:')) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
} else if (media.path.startsWith('blob:')) {
|
||||
img.src = media.path
|
||||
} else {
|
||||
img.src = `/api/annotations/media/${media.id}/file`
|
||||
}
|
||||
@@ -51,10 +86,22 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
imgRef.current = img
|
||||
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
}
|
||||
}, [media, annotation])
|
||||
}, [media, annotation, isVideo])
|
||||
|
||||
useEffect(() => { loadImage() }, [loadImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo || !containerRef.current) return
|
||||
const update = () => {
|
||||
const c = containerRef.current
|
||||
if (c) setImgSize({ w: c.clientWidth, h: c.clientHeight })
|
||||
}
|
||||
update()
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(containerRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [isVideo])
|
||||
|
||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
||||
x: nx * imgSize.w * zoom + pan.x,
|
||||
y: ny * imgSize.h * zoom + pan.y,
|
||||
@@ -68,7 +115,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx || !imgRef.current) return
|
||||
if (!canvas || !ctx) return
|
||||
if (!isVideo && !imgRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
@@ -78,7 +126,9 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.save()
|
||||
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
|
||||
if (!isVideo && imgRef.current) {
|
||||
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
|
||||
}
|
||||
|
||||
const timeWindowDets = getTimeWindowDetections()
|
||||
const allDets = [...detections, ...timeWindowDets]
|
||||
@@ -90,7 +140,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
const w = det.width * imgSize.w * zoom
|
||||
const h = det.height * imgSize.h * zoom
|
||||
|
||||
const color = AFFILIATION_COLORS[det.affiliation] || '#FFD700'
|
||||
const color = getClassColor(det.classNum)
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = isSelected ? 2 : 1
|
||||
ctx.strokeRect(cx, cy, w, h)
|
||||
@@ -100,12 +150,20 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
ctx.fillRect(cx, cy, w, h)
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const label = det.confidence < 0.995
|
||||
? `${det.label} ${(det.confidence * 100).toFixed(0)}%`
|
||||
: det.label
|
||||
ctx.fillStyle = color
|
||||
const name = det.label || getClassNameFallback(det.classNum)
|
||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
||||
const label = `${name}${modeSuffix}${confSuffix}`
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText(label, cx + 2, cy - 3)
|
||||
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'
|
||||
@@ -150,7 +208,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
}, [draw])
|
||||
|
||||
const getTimeWindowDetections = (): Detection[] => {
|
||||
if (media.mediaType !== 2) return []
|
||||
if (media.mediaType !== MediaType.Video) return []
|
||||
if (annotation) return []
|
||||
const timeTicks = currentTime * 10_000_000
|
||||
return annotations
|
||||
.filter(a => {
|
||||
@@ -332,7 +391,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
}, [detections, selected, onDetectionsChange])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
|
||||
<div ref={containerRef} className="w-full h-full flex-1 relative overflow-hidden cursor-crosshair">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
@@ -344,4 +403,6 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default CanvasEditor
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
const [filter, setFilter] = useState('')
|
||||
const debouncedFilter = useDebounce(filter, 300)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
const params = new URLSearchParams({ pageSize: '1000' })
|
||||
@@ -27,57 +29,124 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
||||
setMedia(res.items)
|
||||
} catch {}
|
||||
setMedia(prev => {
|
||||
// Keep local-only (blob URL) entries, merge with backend entries
|
||||
const local = prev.filter(m => m.path.startsWith('blob:'))
|
||||
return [...local, ...res.items]
|
||||
})
|
||||
} catch {
|
||||
// backend unavailable — keep local-only entries
|
||||
}
|
||||
}, [selectedFlight, debouncedFilter])
|
||||
|
||||
useEffect(() => { fetchMedia() }, [fetchMedia])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const m of media) if (m.path.startsWith('blob:')) URL.revokeObjectURL(m.path)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleSelect = async (m: Media) => {
|
||||
onSelect(m)
|
||||
if (m.path.startsWith('blob:')) {
|
||||
onAnnotationsLoaded([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
||||
)
|
||||
onAnnotationsLoaded(res.items)
|
||||
} catch {}
|
||||
} catch {
|
||||
onAnnotationsLoaded([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/annotations/media/${deleteId}`)
|
||||
const target = media.find(m => m.id === deleteId)
|
||||
if (target?.path.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(target.path)
|
||||
setMedia(prev => prev.filter(m => m.id !== deleteId))
|
||||
setDeleteId(null)
|
||||
return
|
||||
}
|
||||
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
|
||||
setDeleteId(null)
|
||||
fetchMedia()
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
if (!selectedFlight || !e.dataTransfer.files.length) return
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of e.dataTransfer.files) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
fetchMedia()
|
||||
}
|
||||
const uploadFiles = useCallback(async (files: File[] | FileList) => {
|
||||
if (!files.length) return
|
||||
const arr = Array.from(files)
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) return
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of e.target.files) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
fetchMedia()
|
||||
// Try backend first
|
||||
if (selectedFlight) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of arr) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
fetchMedia()
|
||||
return
|
||||
} catch {
|
||||
// fall through to local mode
|
||||
}
|
||||
}
|
||||
|
||||
// Local mode: add blob URL entries to state
|
||||
const videoExts = /\.(mp4|mov|webm|mkv|avi|m4v|ogg|ogv)$/i
|
||||
const imageExts = /\.(jpe?g|png|webp|gif|bmp|tiff?)$/i
|
||||
const accepted: File[] = []
|
||||
const rejected: string[] = []
|
||||
for (const file of arr) {
|
||||
const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
|
||||
const isImage = file.type.startsWith('image/') || imageExts.test(file.name)
|
||||
if (isVideo || isImage) accepted.push(file)
|
||||
else rejected.push(file.name)
|
||||
}
|
||||
if (rejected.length) {
|
||||
alert(`Unsupported file type (video/image only):\n${rejected.join('\n')}`)
|
||||
}
|
||||
|
||||
const localItems: Media[] = accepted.map(file => {
|
||||
const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
|
||||
return {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
name: file.name,
|
||||
path: URL.createObjectURL(file),
|
||||
mediaType: isVideo ? MediaType.Video : MediaType.Image,
|
||||
mediaStatus: 0,
|
||||
duration: null,
|
||||
annotationCount: 0,
|
||||
waypointId: null,
|
||||
userId: 'local',
|
||||
}
|
||||
})
|
||||
setMedia(prev => [...localItems, ...prev])
|
||||
}, [selectedFlight, fetchMedia])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: uploadFiles,
|
||||
multiple: true,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
})
|
||||
|
||||
const handleFolderInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) uploadFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden ${dragging ? 'ring-2 ring-az-orange ring-inset' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
{...getRootProps({
|
||||
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="p-2 border-b border-az-border flex gap-1">
|
||||
<input
|
||||
value={filter}
|
||||
@@ -85,13 +154,40 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
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"
|
||||
/>
|
||||
<label className="bg-az-orange text-white text-xs px-2 py-1 rounded cursor-pointer">
|
||||
↑
|
||||
<input type="file" multiple className="hidden" onChange={handleFileUpload} />
|
||||
</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
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
if (e.target.files?.length) uploadFiles(e.target.files)
|
||||
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
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={handleFolderInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{media.map(m => (
|
||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => handleSelect(m)}
|
||||
@@ -100,8 +196,8 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
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 === 2 ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
||||
{m.mediaType === 2 ? 'V' : 'P'}
|
||||
<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'}`}>
|
||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{m.name}</span>
|
||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { getToken } from '../../api/client'
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
onTimeUpdate: (time: number) => void
|
||||
selectedClassNum: number
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: Props) {
|
||||
const { t } = useTranslation()
|
||||
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 {
|
||||
seek: (seconds: number) => void
|
||||
getVideoElement: () => HTMLVideoElement | null
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(seconds: number) {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = seconds
|
||||
setCurrentTime(seconds)
|
||||
}
|
||||
},
|
||||
getVideoElement() {
|
||||
return videoRef.current
|
||||
},
|
||||
}))
|
||||
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 token = getToken()
|
||||
const videoUrl = `/api/annotations/media/${media.id}/file`
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: `/api/annotations/media/${media.id}/file`
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
@@ -64,48 +83,88 @@ export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: P
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
muted={muted}
|
||||
className="w-full max-h-[50vh] object-contain"
|
||||
onTimeUpdate={e => {
|
||||
const t = (e.target as HTMLVideoElement).currentTime
|
||||
setCurrentTime(t)
|
||||
onTimeUpdate(t)
|
||||
}}
|
||||
onLoadedMetadata={e => setDuration((e.target as HTMLVideoElement).duration)}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="h-1 bg-az-border cursor-pointer"
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
if (videoRef.current) videoRef.current.currentTime = pct * duration
|
||||
}}
|
||||
>
|
||||
<div className="h-full bg-az-orange" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
muted={muted}
|
||||
controls={false}
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onTimeUpdate={e => {
|
||||
const t = (e.target as HTMLVideoElement).currentTime
|
||||
setCurrentTime(t)
|
||||
onTimeUpdate(t)
|
||||
}}
|
||||
onLoadedMetadata={e => {
|
||||
setDuration((e.target as HTMLVideoElement).duration)
|
||||
setError(null)
|
||||
}}
|
||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||
/>
|
||||
{children && <div className="absolute inset-0">{children}</div>}
|
||||
</div>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-az-header text-xs">
|
||||
<button onClick={togglePlay} className="text-az-text hover:text-white px-1">{playing ? '⏸' : '▶'}</button>
|
||||
<button onClick={stop} className="text-az-text hover:text-white px-1">⏹</button>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} className="text-az-muted hover:text-white px-0.5">-{n}</button>
|
||||
))}
|
||||
<span className="text-az-muted mx-1">|</span>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} className="text-az-muted hover:text-white px-0.5">+{n}</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setMuted(m => !m)} className="text-az-text hover:text-white px-1">
|
||||
{muted ? '🔇' : '🔊'}
|
||||
{/* 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>
|
||||
<span className="text-az-muted">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default VideoPlayer
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const CLASS_COLORS = [
|
||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080',
|
||||
]
|
||||
|
||||
export const FALLBACK_CLASS_NAMES = [
|
||||
'Car', 'Person', 'Truck', 'Bicycle', 'Motorcycle', 'Bus',
|
||||
'Animal', 'Tree', 'Building', 'Sign', 'Boat', 'Plane',
|
||||
]
|
||||
|
||||
export function getClassColor(classNum: number): string {
|
||||
const base = classNum % 20
|
||||
return CLASS_COLORS[base % CLASS_COLORS.length]
|
||||
}
|
||||
|
||||
export function getPhotoModeSuffix(classNum: number): string {
|
||||
const mode = Math.floor(classNum / 20)
|
||||
return mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
|
||||
}
|
||||
|
||||
export function getClassNameFallback(classNum: number): string {
|
||||
const base = classNum % 20
|
||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightspage.tsx","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
<<<<<<< Updated upstream
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightspage.tsx","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
=======
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.jsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/components/annotationcontrols.jsx","./src/features/annotations/components/annotationlist.jsx","./src/features/annotations/components/annotationmain.jsx","./src/features/annotations/components/canvaseditor.jsx","./src/features/annotations/components/detection.jsx","./src/features/annotations/components/detectionclasslist.jsx","./src/features/annotations/components/detectioncontainer.jsx","./src/features/annotations/components/medialist.jsx","./src/features/annotations/components/videoplayer.jsx","./src/features/annotations/constants/detectiontypes.js","./src/features/annotations/icons/cleanicon.jsx","./src/features/annotations/icons/deleteicon.jsx","./src/features/annotations/icons/nexticon.jsx","./src/features/annotations/icons/pauseicon.jsx","./src/features/annotations/icons/playicon.jsx","./src/features/annotations/icons/previousicon.jsx","./src/features/annotations/icons/saveicon.jsx","./src/features/annotations/icons/stopicon.jsx","./src/features/annotations/models/detectionclass.js","./src/features/annotations/services/annotationservice.js","./src/features/annotations/services/backendservice.js","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
Reference in New Issue
Block a user