feat: setup dataset enrichment app codebase and scripts

This commit is contained in:
asus committed 2026-08-05 11:52:27 +07:00
1 parent b5c28cc98a
commit d07578462e
72 files changed
+11370

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234c8dff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5'/%3E%3C/svg%3E"
/>
<title>Dataset Enrichment</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
# A base model upload is a whole .pt checkpoint; the default 1 MB would
# reject every one of them.
client_max_body_size 512m;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Video seeking and job logs both want bytes as they come, not after
# nginx has collected a full buffer.
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
}
}
+1665
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^7.1.5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+143
View File
@@ -0,0 +1,143 @@
import { Component, useEffect, useState } from 'react'
import { api } from './api'
import Sidebar from './components/Sidebar'
import ProjectsPage from './pages/ProjectsPage'
import LibraryPage from './pages/LibraryPage'
import TrimPage from './pages/TrimPage'
import ReviewPage from './pages/ReviewPage'
import ModelsPage from './pages/ModelsPage'
import './roboflow.css'
function parseRoute(hash) {
const rawPath = (hash || '').replace(/^#/, '') || '/projects'
const [path, queryString] = rawPath.split('?')
const query = new URLSearchParams(queryString || '')
const parts = path.split('/').filter(Boolean)
if (parts[0] === 'batches' && parts[1]) {
return { name: 'review', batchId: Number(parts[1]) }
}
if (parts[0] === 'projects' && parts[1]) {
const projectId = Number(parts[1])
if (parts[2] === 'trim' && parts[3]) {
return { name: 'trim', projectId, rel: decodeURIComponent(parts[3]) }
}
if (parts[2] === 'models') return { name: 'models', projectId }
if (parts[2] === 'review') {
const batchId = query.get('batch') ? Number(query.get('batch')) : null
return { name: 'review', projectId, batchId }
}
return { name: 'library', projectId }
}
return { name: 'projects' }
}
export function navigate(path) {
window.location.hash = path
}
function useRoute() {
const [route, setRoute] = useState(() => parseRoute(window.location.hash))
useEffect(() => {
const onChange = () => setRoute(parseRoute(window.location.hash))
window.addEventListener('hashchange', onChange)
return () => window.removeEventListener('hashchange', onChange)
}, [])
return route
}
function useTheme() {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark')
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}, [theme])
return [theme, () => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))]
}
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
console.error('UI Exception:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#f3f4f6' }}>
<h2>View Exception Caught</h2>
<p style={{ color: '#ef4444', margin: '12px 0' }}>{this.state.error?.toString()}</p>
<button className="btn btn-primary" onClick={() => window.location.reload()}>
Reload Application
</button>
</div>
)
}
return this.props.children
}
}
export default function App() {
const route = useRoute()
const [theme, toggleTheme] = useTheme()
const [currentProject, setCurrentProject] = useState(null)
useEffect(() => {
if (route.projectId) {
api.getProject(route.projectId).then(setCurrentProject).catch(() => {})
} else if (!currentProject) {
api.listProjects().then((list) => {
if (list.length > 0) setCurrentProject(list[0])
}).catch(() => {})
}
}, [route.projectId, route.name, currentProject])
return (
<div className="roboflow-layout">
<Sidebar
route={route}
currentProject={currentProject}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="roboflow-main">
<ErrorBoundary key={route.name + (route.batchId || route.projectId || '')}>
{route.name === 'projects' && <ProjectsPage />}
{route.name === 'library' && (
<LibraryPage
projectId={route.projectId}
onProject={(p) => setCurrentProject(p)}
/>
)}
{route.name === 'trim' && <TrimPage projectId={route.projectId} rel={route.rel} />}
{route.name === 'review' && (
<ReviewPage
batchId={route.batchId}
projectId={route.projectId}
onProject={(p) => setCurrentProject(p)}
/>
)}
{route.name === 'models' && (
<ModelsPage
projectId={route.projectId}
onProject={(p) => setCurrentProject(p)}
/>
)}
</ErrorBoundary>
</main>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
/* One place that knows how to talk to the backend.
*
* FastAPI reports failures as {"detail": "..."} — unwrapped here so callers can
* show the message the backend actually wrote instead of "500". */
async function request(path, { method = 'GET', body, form } = {}) {
const options = { method, headers: {} }
if (form) {
options.body = form
} else if (body !== undefined) {
options.headers['Content-Type'] = 'application/json'
options.body = JSON.stringify(body)
}
const response = await fetch(`/api${path}`, options)
const text = await response.text()
const payload = text ? JSON.parse(text) : null
if (!response.ok) {
const detail = payload?.detail
throw new Error(typeof detail === 'string' ? detail : `${response.status} ${response.statusText}`)
}
return payload
}
export const api = {
health: () => request('/health'),
listProjects: () => request('/projects'),
getProject: (id) => request(`/projects/${id}`),
createProject: (body) => request('/projects', { method: 'POST', body }),
patchProject: (id, body) => request(`/projects/${id}`, { method: 'PATCH', body }),
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
addClass: (id, body) => request(`/projects/${id}/classes`, { method: 'POST', body }),
deleteClass: (id, classId) =>
request(`/projects/${id}/classes/${classId}`, { method: 'DELETE' }),
uploadBaseModel: (id, file) => {
const form = new FormData()
form.append('file', file)
return request(`/projects/${id}/base-model`, { method: 'POST', form })
},
uploadSecondaryModel: (id, file) => {
const form = new FormData()
form.append('file', file)
return request(`/projects/${id}/secondary-model`, { method: 'POST', form })
},
listDates: (id) => request(`/projects/${id}/library`),
listVideos: (id, date) => request(`/projects/${id}/library/${encodeURIComponent(date)}`),
videoInfo: (id, rel) => request(`/projects/${id}/video/info?rel=${encodeURIComponent(rel)}`),
videoUrl: (id, rel) => `/api/projects/${id}/video?rel=${encodeURIComponent(rel)}`,
createBatch: (id, body) => request(`/projects/${id}/batches`, { method: 'POST', body }),
listBatches: (id) => request(`/projects/${id}/batches`),
getBatch: (id) => request(`/batches/${id}`),
patchBatch: (id, body) => request(`/batches/${id}`, { method: 'PATCH', body }),
deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }),
listFrames: (id) => request(`/batches/${id}/frames`),
frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`,
startAutolabel: (batchId, body) =>
request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }),
clearBatchClassAnnotations: (batchId, classId) =>
request(`/batches/${batchId}/classes/${classId}/annotations`, { method: 'DELETE' }),
nextPending: (batchId, afterIdx = -1) =>
request(`/batches/${batchId}/next-pending?after_idx=${afterIdx}`),
frameAnnotations: (frameId) => request(`/frames/${frameId}/annotations`),
addAnnotation: (frameId, body) =>
request(`/frames/${frameId}/annotations`, { method: 'POST', body }),
patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }),
deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }),
assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }),
setFrameStatus: (frameId, status) =>
request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }),
approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }),
approveBatch: (batchId) => request(`/batches/${batchId}/approve`, { method: 'POST' }),
datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`),
datasetDownloadUrl: (projectId) => `/api/projects/${projectId}/dataset/download`,
hardware: () => request('/hardware'),
startTraining: (projectId, body) =>
request(`/projects/${projectId}/train`, { method: 'POST', body }),
listModels: (projectId) => request(`/projects/${projectId}/models`),
promoteModel: (modelId) => request(`/models/${modelId}/promote`, { method: 'POST' }),
weightsUrl: (modelId) => `/api/models/${modelId}/weights`,
listJobs: (projectId) => request(`/jobs${projectId ? `?project_id=${projectId}` : ''}`),
getJob: (id) => request(`/jobs/${id}`),
cancelJob: (id) => request(`/jobs/${id}/cancel`, { method: 'POST' }),
}
export function classColor(classId) {
return `var(--class-${classId % 8})`
}
export function formatDuration(seconds) {
// 0 is a real timestamp — a trim starting at the first frame reads 0:00, not a dash.
if (seconds == null || Number.isNaN(seconds)) return '—'
const total = Math.round(seconds)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}
+681
View File
@@ -0,0 +1,681 @@
.app {
width: 100vw;
height: 100vh;
overflow: hidden !important;
display: flex;
flex-direction: column;
background: #0b0f19;
}
.topbar {
height: 52px;
margin: 10px 14px 0 14px;
padding: 0 18px;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
z-index: 100;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.topbar .brand {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 700;
letter-spacing: 0.02em;
}
.topbar .brand svg { color: var(--accent); }
.topbar .spacer { flex: 1; }
.crumbs {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-size: 13px;
min-width: 0;
background: rgba(0, 0, 0, 0.25);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.crumbs svg { color: var(--accent); flex: none; }
.crumbs .current {
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.health {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.25);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-faint);
flex: none;
}
.dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.dot.bad { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
main.page {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px;
width: 100%;
max-width: 100%;
margin: 0;
display: flex;
flex-direction: column;
}
.page-head {
display: flex;
align-items: flex-start;
gap: calc(var(--space) * 2);
margin-bottom: 18px;
}
.page-head .spacer { flex: 1; }
/* --- project cards ---------------------------------------------------- */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
width: 100%;
}
.project-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
text-align: left;
color: inherit;
transition: border-color var(--transition), transform var(--transition),
box-shadow var(--transition);
}
.project-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow);
}
.project-card .title-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.project-card .title-row h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
font-size: 12px;
}
.project-card dt { color: var(--text-faint); }
.project-card dd { margin: 0; color: var(--text-muted); }
.project-card .classes {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.class-tag { padding-right: 3px; }
.chip-x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-faint);
transition: background var(--transition), color var(--transition);
}
.chip-x:hover:not(:disabled) {
background: var(--danger-soft);
color: var(--danger);
}
.project-card .actions {
display: flex;
gap: 8px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.project-card .actions .spacer { flex: 1; }
/* --- forms ------------------------------------------------------------ */
.form-panel {
padding: 18px;
display: grid;
gap: 14px;
max-width: 720px;
}
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.class-rows {
display: grid;
gap: 8px;
}
.class-row {
display: grid;
grid-template-columns: 18px 1fr 1fr auto;
align-items: center;
gap: 8px;
}
.form-actions {
display: flex;
gap: 8px;
align-items: center;
}
.hint {
font-size: 12px;
color: var(--text-faint);
}
/* --- library ---------------------------------------------------------- */
.library {
display: grid;
grid-template-columns: 220px 1fr;
gap: 16px;
align-items: start;
height: calc(100vh - 120px);
overflow: hidden;
}
.date-list {
display: flex;
flex-direction: column;
padding: 6px;
gap: 2px;
height: 100%;
overflow-y: auto;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
}
.date-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font: inherit;
text-align: left;
transition: background var(--transition), color var(--transition);
}
.date-item:hover { background: var(--panel-raised); color: var(--text); }
.date-item[aria-current='true'] {
background: var(--accent-soft);
color: var(--text);
font-weight: 600;
border: 1px solid rgba(168, 85, 247, 0.4);
}
.date-item .count { margin-left: auto; font-size: 12px; color: var(--text-faint); }
.video-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.video-table th {
text-align: left;
padding: 8px 12px;
color: var(--text-faint);
font-size: 12px;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
.video-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
}
.video-table tbody tr {
transition: background var(--transition);
}
.video-table tbody tr:hover { background: var(--panel-raised); }
.video-table td:first-child { color: var(--text); font-weight: 500; }
.video-table td.num { font-family: var(--mono); font-size: 12px; }
.table-wrap {
overflow: visible;
max-height: none;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
padding: 8px;
}
/* --- trim ------------------------------------------------------------- */
.trim {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
height: calc(100vh - 120px);
overflow: hidden;
}
.trim-player {
padding: 8px;
background: #000;
overflow: hidden;
}
.trim-player video {
display: block;
width: 100%;
max-height: 70vh;
border-radius: var(--radius-sm);
}
.trim-controls {
padding: 16px;
position: sticky;
top: 68px;
}
.range-group {
display: grid;
gap: 6px;
}
.range-group input[type='range'] {
width: 100%;
padding: 0;
height: 22px;
accent-color: var(--accent);
background: transparent;
border: none;
cursor: pointer;
}
.range-group input[type='range']:focus { box-shadow: none; }
.job-status {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-raised);
font-size: 13px;
}
.progress {
height: 6px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
background: var(--accent);
transition: width var(--transition);
}
/* --- review ----------------------------------------------------------- */
.panel {
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.review {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px;
flex: 1;
min-height: 500px;
width: 100%;
}
.review-main {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
min-width: 0;
}
.canvas-wrap {
position: relative;
display: inline-block;
margin: 0 auto;
background: #030712;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
overflow: hidden;
line-height: 0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.canvas-wrap img {
display: block;
width: 100%;
height: auto;
user-select: none;
}
.canvas-wrap svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
.canvas-wrap svg.assist { cursor: copy; }
.canvas-wrap .shape rect,
.canvas-wrap .shape polygon {
fill: rgba(255, 255, 255, 0.05);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: move;
}
.canvas-wrap .shape:hover rect,
.canvas-wrap .shape:hover polygon { stroke-width: 3; }
@keyframes shape-march {
to {
stroke-dashoffset: -20;
}
}
.canvas-wrap .shape.selected rect,
.canvas-wrap .shape.selected polygon {
stroke-width: 3.5;
stroke-dasharray: 8 4;
animation: shape-march 0.8s linear infinite;
fill: rgba(255, 255, 255, 0.22);
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.85));
}
.canvas-wrap .handle {
cursor: nwse-resize;
stroke: #ffffff;
stroke-width: 2;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.9));
vector-effect: non-scaling-stroke;
}
.canvas-wrap .handle-ne, .canvas-wrap .handle-sw { cursor: nesw-resize; }
.canvas-wrap .handle-vertex, .canvas-wrap .handle-midpoint { cursor: pointer; }
.canvas-wrap .draft {
fill: rgba(255, 255, 255, 0.08);
stroke-width: 2;
stroke-dasharray: 5 4;
vector-effect: non-scaling-stroke;
}
.frame-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
flex-shrink: 0;
}
.frame-bar .spacer { flex: 1; }
.status-pill {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-family: var(--font);
background: var(--panel-raised);
color: var(--text-muted);
}
.status-pill.approved { background: rgba(16, 185, 129, 0.2); color: var(--ok); border: 1px solid rgba(16, 185, 129, 0.4); }
.status-pill.rejected { background: var(--danger-soft); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.4); }
.filmstrip {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 8px;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius);
scrollbar-width: thin;
flex-shrink: 0;
}
.thumb {
position: relative;
flex: none;
width: 92px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-sm);
background: none;
overflow: hidden;
line-height: 0;
transition: border-color var(--transition), opacity var(--transition);
}
.thumb img { width: 100%; display: block; }
.thumb.approved { border-color: var(--ok); }
.thumb.rejected { border-color: var(--danger); opacity: 0.45; }
.thumb:hover { border-color: var(--text-faint); }
.thumb[aria-current='true'] { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
.thumb .badge {
position: absolute;
right: 3px;
bottom: 3px;
padding: 0 5px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 10px;
line-height: 16px;
}
.review-side {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding-right: 4px;
}
.side-panel { padding: 12px 14px; display: grid; gap: 10px; }
.class-list, .shape-list { display: grid; gap: 4px; }
.class-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font: inherit;
text-align: left;
transition: background var(--transition), color var(--transition);
}
.class-chip:hover { background: var(--panel-raised); color: var(--text); }
.class-chip.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
}
.class-chip .mono { margin-left: auto; }
.shape-list { list-style: none; margin: 0; padding: 0; }
.shape-list li {
display: flex;
align-items: center;
gap: 4px;
border-radius: var(--radius-sm);
}
.shape-list li.selected { background: var(--accent-soft); }
.shape-pick {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
text-align: left;
}
.shape-pick:hover { color: var(--text); }
.shape-pick .mono { margin-left: auto; }
.metrics td.better { color: var(--ok); }
.metrics td.worse { color: var(--danger); }
.job-log {
margin: 0;
padding: 8px;
max-height: 180px;
overflow: auto;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1px solid var(--border);
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
white-space: pre-wrap;
}
.shortcuts { margin: 0; display: grid; gap: 5px; font-size: 12px; }
.shortcuts > div { display: flex; gap: 10px; align-items: baseline; }
.shortcuts dt { flex: none; width: 62px; }
.shortcuts dd { margin: 0; color: var(--text-muted); }
kbd {
padding: 1px 5px;
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--panel-raised);
font-family: var(--mono);
font-size: 11px;
color: var(--text);
}
@media (max-width: 1000px) {
.trim { grid-template-columns: 1fr; }
.trim-controls { position: static; }
.review { grid-template-columns: 1fr; }
.review-side { position: static; }
}
@media (max-width: 900px) {
.library { grid-template-columns: 1fr; }
.date-list { position: static; flex-direction: row; flex-wrap: wrap; }
}
@media (max-width: 560px) {
.topbar .crumbs { display: none; }
main.page { padding: 16px 12px 40px; }
}
@@ -0,0 +1,240 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { classColor } from '../api'
import Shape from './Shape'
/* The annotation surface: the frame with an SVG overlay on top.
*
* SVG rather than <canvas> on purpose — shapes are elements, so selection,
* hover and focus come from the DOM instead of hand-written hit-testing, and
* the whole thing stays keyboard-reachable.
*
* Geometry is normalized 0–1 everywhere; only the handle size is converted to
* frame units, so it stays the same physical size at any zoom. */
const HANDLE_PX = 12
const MIN_SIZE = 0.004
const CORNERS = [
['nw', 0, 0], ['ne', 1, 0], ['se', 1, 1], ['sw', 0, 1],
]
function boxPoints(geometry) {
return geometry.type === 'bbox'
? geometry.points
: (() => {
const xs = geometry.points.map((p) => p[0])
const ys = geometry.points.map((p) => p[1])
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
})()
}
function normalise([x0, y0, x1, y1]) {
return [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)]
}
export default function AnnotationCanvas({
frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes,
onSelect, onCreate, onUpdate, onAssist,
}) {
const wrapRef = useRef(null)
const svgRef = useRef(null)
const [displayWidth, setDisplayWidth] = useState(0)
const [draft, setDraft] = useState(null) // box being drawn
const [drag, setDrag] = useState(null) // move/resize in progress
useLayoutEffect(() => {
const element = wrapRef.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
setDisplayWidth(entry.contentRect.width)
})
observer.observe(element)
return () => observer.disconnect()
}, [])
const width = frame?.width || 1
const height = frame?.height || 1
const scale = displayWidth ? width / displayWidth : 1
const handle = HANDLE_PX * scale
function pointAt(event) {
const rect = svgRef.current.getBoundingClientRect()
return [
Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
]
}
function startDraw(event) {
if (event.button !== 0) return
const [x, y] = pointAt(event)
onSelect(null)
setDraft([x, y, x, y])
event.currentTarget.setPointerCapture(event.pointerId)
}
function startMove(event, annotation) {
event.stopPropagation()
const [x, y] = pointAt(event)
onSelect(annotation.id)
setDrag({ kind: 'move', id: annotation.id, origin: [x, y],
start: annotation.geometry })
event.currentTarget.setPointerCapture(event.pointerId)
}
function startResize(event, annotation, corner) {
event.stopPropagation()
onSelect(annotation.id)
setDrag({ kind: 'resize', id: annotation.id, corner,
start: annotation.geometry })
event.currentTarget.setPointerCapture(event.pointerId)
}
function startVertex(event, annotation, pointIndex) {
event.stopPropagation()
onSelect(annotation.id)
setDrag({ kind: 'vertex', id: annotation.id, pointIndex, start: annotation.geometry })
event.currentTarget.setPointerCapture(event.pointerId)
}
function startMidpoint(event, annotation, afterIndex, newPoint) {
event.stopPropagation()
onSelect(annotation.id)
const newPoints = [...annotation.geometry.points]
newPoints.splice(afterIndex + 1, 0, newPoint)
const newGeom = { type: 'polygon', points: newPoints }
onUpdate(annotation.id, newGeom, { local: true })
setDrag({ kind: 'vertex', id: annotation.id, pointIndex: afterIndex + 1, start: newGeom })
event.currentTarget.setPointerCapture(event.pointerId)
}
function deleteVertex(annotation, pointIndex) {
if (annotation.geometry.points.length <= 3) return
const newPoints = annotation.geometry.points.filter((_, i) => i !== pointIndex)
onUpdate(annotation.id, { type: 'polygon', points: newPoints }, { commit: true })
}
function onPointerMove(event) {
if (draft) {
const [x, y] = pointAt(event)
setDraft([draft[0], draft[1], x, y])
return
}
if (!drag) return
const [x, y] = pointAt(event)
if (drag.kind === 'vertex') {
const points = drag.start.points.map((p, i) => (i === drag.pointIndex ? [x, y] : p))
onUpdate(drag.id, { type: 'polygon', points }, { local: true })
return
}
if (drag.kind === 'move') {
const [dx, dy] = [x - drag.origin[0], y - drag.origin[1]]
onUpdate(drag.id, shift(drag.start, dx, dy), { local: true })
} else {
const [x0, y0, x1, y1] = boxPoints(drag.start)
const next = drag.corner === 'nw' ? [x, y, x1, y1]
: drag.corner === 'ne' ? [x0, y, x, y1]
: drag.corner === 'se' ? [x0, y0, x, y]
: [x, y0, x1, y]
onUpdate(drag.id, { type: 'bbox', points: normalise(next) }, { local: true })
}
}
function onPointerUp() {
if (draft) {
const [x0, y0, x1, y1] = normalise(draft)
setDraft(null)
if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) {
if (assistMode) onAssist([x0, y0, x1, y1])
else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] })
}
return
}
if (drag) {
onUpdate(drag.id, null, { commit: true })
setDrag(null)
}
}
// Escape cancels whatever gesture is in flight rather than committing it.
useEffect(() => {
const onKey = (event) => {
if (event.key !== 'Escape') return
setDraft(null)
setDrag(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// Bound the height by bounding the width at the frame's aspect ratio: a
// portrait frame would otherwise be three screens tall, and constraining the
// image itself would leave the SVG overlay misaligned with it.
return (
<div
className="canvas-wrap"
ref={wrapRef}
style={{ maxWidth: `calc(72vh * ${width} / ${height})` }}
>
<img src={imageUrl} alt={`Frame ${frame?.idx ?? ''}`} draggable={false} />
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={assistMode ? 'assist' : undefined}
onPointerDown={startDraw}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{annotations.map((annotation) => (
<Shape
key={annotation.id}
annotation={annotation}
width={width}
height={height}
scale={scale}
handle={handle}
selected={annotation.id === selectedId}
classes={classes}
onStartMove={startMove}
onStartResize={startResize}
onStartVertex={startVertex}
onStartMidpoint={startMidpoint}
onDeleteVertex={deleteVertex}
/>
))}
{draft && (() => {
const [x0, y0, x1, y1] = normalise(draft)
return (
<rect
className={assistMode ? 'draft assist' : 'draft'}
x={x0 * width} y={y0 * height}
width={(x1 - x0) * width} height={(y1 - y0) * height}
stroke={assistMode ? 'var(--accent)' : classColor(activeClass)}
/>
)
})()}
</svg>
</div>
)
}
function shift(geometry, dx, dy) {
if (geometry.type === 'bbox') {
const [x0, y0, x1, y1] = geometry.points
const clampedX = Math.min(Math.max(dx, -x0), 1 - x1)
const clampedY = Math.min(Math.max(dy, -y0), 1 - y1)
return { type: 'bbox',
points: [x0 + clampedX, y0 + clampedY, x1 + clampedX, y1 + clampedY] }
}
const xs = geometry.points.map((p) => p[0])
const ys = geometry.points.map((p) => p[1])
const clampedX = Math.min(Math.max(dx, -Math.min(...xs)), 1 - Math.max(...xs))
const clampedY = Math.min(Math.max(dy, -Math.min(...ys)), 1 - Math.max(...ys))
return { type: 'polygon',
points: geometry.points.map(([x, y]) => [x + clampedX, y + clampedY]) }
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react'
import { api } from '../api'
export default function Filmstrip({ frames, index, onSelectIndex, stripRef }) {
return (
<div className="filmstrip" ref={stripRef}>
{frames.map((item, position) => (
<button
key={item.id}
className={`thumb ${item.review_status}`}
aria-current={position === index}
onClick={() => onSelectIndex(position)}
title={`${item.filename} — ${item.review_status}`}
>
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
</button>
))}
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
/* Inline SVG icons (Lucide geometry). No emoji as icons — see ../../../AGENTS.md
* section 7. They inherit currentColor and the surrounding font size. */
function Icon({ children, size = 16, ...rest }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{children}
</svg>
)
}
export const PlusIcon = (props) => (
<Icon {...props}><path d="M12 5v14M5 12h14" /></Icon>
)
export const TrashIcon = (props) => (
<Icon {...props}>
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
</Icon>
)
export const UploadIcon = (props) => (
<Icon {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</Icon>
)
export const SunIcon = (props) => (
<Icon {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Icon>
)
export const MoonIcon = (props) => (
<Icon {...props}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></Icon>
)
export const LayersIcon = (props) => (
<Icon {...props}>
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</Icon>
)
export const ChevronRightIcon = (props) => (
<Icon {...props}><path d="m9 18 6-6-6-6" /></Icon>
)
export const AlertIcon = (props) => (
<Icon {...props}>
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
<path d="M12 9v4M12 17h.01" />
</Icon>
)
export const CheckIcon = (props) => (
<Icon {...props}><path d="M20 6 9 17l-5-5" /></Icon>
)
export const XIcon = (props) => (
<Icon {...props}><path d="M18 6 6 18M6 6l12 12" /></Icon>
)
export const FolderIcon = (props) => (
<Icon {...props}>
<path d="M20 20H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l2 3h10a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2z" />
</Icon>
)
export const DatabaseIcon = (props) => (
<Icon {...props}>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</Icon>
)
export const CpuIcon = (props) => (
<Icon {...props}>
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3" />
</Icon>
)
export const RocketIcon = (props) => (
<Icon {...props}>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" />
<path d="M15 9l-6 6" />
<path d="M9 18l3 3c.87.87 2.18.99 3.1.27l5.9-5.9c1.6-1.6 1.6-4.2 0-5.8l-1.5-1.5c-1.6-1.6-4.2-1.6-5.8 0l-5.9 5.9c-.72.92-.6 2.23.27 3.1z" />
</Icon>
)
export const BarChartIcon = (props) => (
<Icon {...props}>
<path d="M12 20V10M18 20V4M6 20v-4" />
</Icon>
)
export const TagIcon = (props) => (
<Icon {...props}>
<path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2z" />
<circle cx="7" cy="7" r="1.5" />
</Icon>
)
export const SlidersIcon = (props) => (
<Icon {...props}>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</Icon>
)
export const ZapIcon = (props) => (
<Icon {...props}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</Icon>
)
@@ -0,0 +1,28 @@
import React from 'react'
import { classColor } from '../api'
import { TrashIcon } from './Icons'
export default function QuickReclassBar({ classesList, reclass, removeSelected }) {
return (
<div className="quick-reclass-bar panel" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', background: 'rgba(168, 85, 247, 0.18)', border: '1px solid rgba(168, 85, 247, 0.45)', borderRadius: '8px' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#c084fc' }}>
Selected Shape Hotkeys:
</span>
{classesList.map((c) => (
<button
key={c.class_id}
className="btn"
style={{ padding: '3px 10px', fontSize: '0.78rem', background: 'rgba(0,0,0,0.4)', borderColor: classColor(c.class_id), color: '#fff' }}
onClick={() => reclass(c.class_id)}
>
<span style={{ background: classColor(c.class_id), width: 8, height: 8, borderRadius: '50%', display: 'inline-block', marginRight: 5 }} />
<strong>[{c.class_id + 1}]</strong> {c.name}
</button>
))}
<span className="spacer" />
<button className="btn btn-danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={removeSelected}>
<TrashIcon size={12} /> Delete [Del]
</button>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import React from 'react'
import { classColor } from '../api'
import { TrashIcon } from './Icons'
import ShortcutsPanel from './ShortcutsPanel'
export default function ReviewSidebar({
classesList,
activeClass,
reclass,
clearClassInBatch,
annotations,
selectedId,
setSelectedId,
removeSelected,
project,
jumpToNextAnnotated,
batchAnnotationCount,
}) {
return (
<aside className="review-side stack">
<div className="panel side-panel">
<h2>Classes</h2>
<div className="class-list">
{classesList.map((item) => (
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className={`class-chip ${item.class_id === activeClass ? 'active' : ''}`}
style={{ flex: 1 }}
onClick={() => reclass(item.class_id)}
>
<span className="swatch" style={{ background: classColor(item.class_id) }} />
{item.name}
<span className="faint mono">{item.class_id + 1}</span>
</button>
<button
className="btn btn-ghost"
style={{ padding: '4px 6px' }}
title={`Clear all "${item.name}" shapes in this batch`}
onClick={() => clearClassInBatch(item)}
>
<TrashIcon size={12} />
</button>
</div>
))}
</div>
</div>
<div className="panel side-panel">
<h2>Shapes on this frame ({annotations.length})</h2>
{annotations.length === 0 ? (
<div>
<p className="hint" style={{ marginBottom: 8 }}>
None on this frame — drag to draw a shape.
</p>
{batchAnnotationCount > 0 && (
<button
type="button"
className="btn"
style={{
width: '100%',
fontSize: '0.78rem',
borderColor: 'rgba(168, 85, 247, 0.4)',
color: '#c084fc',
background: 'rgba(168, 85, 247, 0.1)',
}}
onClick={jumpToNextAnnotated}
>
🏷️ Jump to Frame with Shapes [N]
</button>
)}
</div>
) : (
<ul className="shape-list">
{annotations.map((item) => (
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
<span className="swatch" style={{ background: classColor(item.class_id) }} />
{project?.classes?.[item.class_id]?.name ?? `class ${item.class_id}`}
<span className="faint mono">
{item.source === 'auto' ? item.score.toFixed(2) : 'manual'}
</span>
</button>
<button
className="btn btn-danger"
aria-label="Delete shape"
onClick={() => {
setSelectedId(item.id)
removeSelected()
}}
>
<TrashIcon size={13} />
</button>
</li>
))}
</ul>
)}
</div>
<ShortcutsPanel />
</aside>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { classColor } from '../api'
const CORNERS = [
['nw', 0, 0], ['ne', 1, 0], ['se', 1, 1], ['sw', 0, 1],
]
function boxPoints(geometry) {
return geometry.type === 'bbox'
? geometry.points
: (() => {
const xs = geometry.points.map((p) => p[0])
const ys = geometry.points.map((p) => p[1])
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
})()
}
export default function Shape({
annotation, width, height, scale, handle, selected, classes,
onStartMove, onStartResize, onStartVertex, onStartMidpoint, onDeleteVertex,
}) {
const colour = classColor(annotation.class_id)
const [x0, y0, x1, y1] = boxPoints(annotation.geometry)
const isPolygon = annotation.geometry.type === 'polygon'
const className = classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}`
return (
<g className={selected ? 'shape selected' : 'shape'}>
{isPolygon ? (
<polygon
points={annotation.geometry.points
.map(([px, py]) => `${px * width},${py * height}`).join(' ')}
stroke={colour}
onPointerDown={(event) => onStartMove(event, annotation)}
/>
) : (
<rect
x={x0 * width} y={y0 * height}
width={(x1 - x0) * width} height={(y1 - y0) * height}
stroke={colour}
onPointerDown={(event) => onStartMove(event, annotation)}
/>
)}
{selected && (
<g transform={`translate(${x0 * width}, ${Math.max(22 * scale, y0 * height - 6 * scale)})`}>
<rect
x="0" y={-16 * scale}
width={Math.max(70 * scale, className.length * 8.5 * scale + 32 * scale)}
height={18 * scale}
rx={3 * scale} ry={3 * scale}
fill="rgba(10, 10, 14, 0.9)"
stroke={colour}
strokeWidth={1.5 * scale}
/>
<text
x={6 * scale} y={-3 * scale}
fill="#ffffff"
fontSize={12 * scale}
fontWeight="bold"
fontFamily="sans-serif"
>
{`[${annotation.class_id + 1}] ${className}`}
</text>
</g>
)}
{selected && !isPolygon && CORNERS.map(([corner, cx, cy]) => (
<rect
key={corner}
className={`handle handle-${corner}`}
x={(x0 + (x1 - x0) * cx) * width - handle / 2}
y={(y0 + (y1 - y0) * cy) * height - handle / 2}
width={handle} height={handle}
fill={colour}
onPointerDown={(event) => onStartResize(event, annotation, corner)}
/>
))}
{selected && isPolygon && (() => {
const points = annotation.geometry.points
const vertices = points.map(([px, py], i) => (
<circle
key={`v-${i}`}
className="handle handle-vertex"
cx={px * width}
cy={py * height}
r={handle / 2}
fill={colour}
onPointerDown={(event) => {
if (event.altKey) {
event.stopPropagation()
if (points.length > 3) {
onDeleteVertex?.(annotation, i)
}
} else {
onStartVertex?.(event, annotation, i)
}
}}
/>
))
const midpoints = points.map(([px, py], i) => {
const next = points[(i + 1) % points.length]
const mx = (px + next[0]) / 2
const my = (py + next[1]) / 2
return (
<circle
key={`m-${i}`}
className="handle handle-midpoint"
cx={mx * width}
cy={my * height}
r={handle / 2.5}
fill={colour}
style={{ opacity: 0.45 }}
onPointerDown={(event) => onStartMidpoint?.(event, annotation, i, [mx, my])}
/>
)
})
return <g>{vertices}{midpoints}</g>
})()}
</g>
)
}
@@ -0,0 +1,37 @@
export default function ShortcutsPanel() {
return (
<div className="panel side-panel">
<h2>Shortcuts</h2>
<dl className="shortcuts">
<div>
<dt><kbd>Drag</kbd></dt>
<dd>Add box / resize / move</dd>
</div>
<div>
<dt><kbd>Hold S</kbd></dt>
<dd>SAM3 assisted shape</dd>
</div>
<div>
<dt><kbd>1</kbd>–<kbd>9</kbd></dt>
<dd>Pick class</dd>
</div>
<div>
<dt><kbd>Del</kbd></dt>
<dd>Remove selected</dd>
</div>
<div>
<dt><kbd>←</kbd> <kbd>→</kbd></dt>
<dd>Prev / next frame</dd>
</div>
<div>
<dt><kbd>A</kbd> <kbd>X</kbd></dt>
<dd>Approve / reject</dd>
</div>
<div>
<dt><kbd>U</kbd></dt>
<dd>Next unreviewed</dd>
</div>
</dl>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { navigate } from '../App'
import {
FolderIcon,
DatabaseIcon,
RocketIcon,
TagIcon,
BarChartIcon,
SunIcon,
MoonIcon,
ChevronRightIcon,
} from './Icons'
export default function Sidebar({ route, currentProject, theme, onToggleTheme }) {
const [health, setHealth] = useState(null)
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sidebar_collapsed') === 'true')
useEffect(() => {
api.health().then(setHealth).catch(() => {})
}, [])
const toggleCollapse = () => {
setCollapsed((prev) => {
const next = !prev
localStorage.setItem('sidebar_collapsed', String(next))
return next
})
}
const pId = currentProject?.id || route.projectId || 1
const handleNav = (e, path) => {
e.preventDefault()
navigate(path)
}
return (
<div className={`roboflow-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<div className="sidebar-logo">{collapsed ? 'DE' : 'Dataset Enrichment'}</div>
<button className="sidebar-collapse-btn" onClick={toggleCollapse} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
{collapsed ? <ChevronRightIcon size={14} /> : '❮'}
</button>
</div>
<div className="sidebar-section">
{!collapsed && <div className="sidebar-section-title">WORKSPACE</div>}
<a href="#/projects" onClick={(e) => handleNav(e, '/projects')} className={`sidebar-item ${route.name === 'projects' ? 'active' : ''}`} title="Projects">
<span className="sidebar-icon"><FolderIcon size={16} /></span>
{!collapsed && <span>Projects</span>}
</a>
</div>
<div className="sidebar-section">
{!collapsed && <div className="sidebar-section-title">DATA</div>}
<a href={`#/projects/${pId}`} onClick={(e) => handleNav(e, `/projects/${pId}`)} className={`sidebar-item ${route.name === 'library' ? 'active' : ''}`} title="Video Archive">
<span className="sidebar-icon"><DatabaseIcon size={16} /></span>
{!collapsed && <span>Video Archive</span>}
</a>
<a
href={route.batchId ? `#/batches/${route.batchId}` : `#/projects/${pId}`}
onClick={(e) => handleNav(e, route.batchId ? `/batches/${route.batchId}` : `/projects/${pId}`)}
className={`sidebar-item ${route.name === 'review' || route.name === 'trim' ? 'active' : ''}`}
title="Annotate / Review"
>
<span className="sidebar-icon"><TagIcon size={16} /></span>
{!collapsed && <span>Annotate / Review</span>}
</a>
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Master Dataset">
<span className="sidebar-icon"><BarChartIcon size={16} /></span>
{!collapsed && <span>Master Dataset</span>}
</a>
</div>
<div className="sidebar-section">
{!collapsed && <div className="sidebar-section-title">MODELS</div>}
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Train & Select Engine">
<span className="sidebar-icon"><RocketIcon size={16} /></span>
{!collapsed && <span>Train & Select Engine</span>}
</a>
</div>
<div className="sidebar-spacer" />
<div className="sidebar-footer">
<button className="sidebar-theme-toggle" onClick={onToggleTheme} title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}>
{collapsed ? (theme === 'dark' ? <SunIcon size={14} /> : <MoonIcon size={14} />) : (theme === 'dark' ? 'Light Mode' : 'Dark Mode')}
</button>
{health && !collapsed && (
<div className="sidebar-health">
<div className="health-item" title={health.gpu || ''} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
<span style={{ float: 'left' }}>GPU:</span>
<span style={{ float: 'right' }}>{health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'N/A'}</span>
</div>
<div className="health-item"><span>VRAM:</span> <span>{health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}</span></div>
<div className="health-item"><span>SAM3:</span> <span>{health.sam3_ready ? 'Ready' : 'Not Ready'}</span></div>
<div className="health-item"><span>FFmpeg:</span> <span>{health.ffmpeg ? 'OK' : 'Error'}</span></div>
</div>
)}
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './theme.css'
import './app.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+591
View File
@@ -0,0 +1,591 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { api, formatDuration } from '../api'
import { navigate } from '../App'
import { AlertIcon } from '../components/Icons'
function megabytes(bytes) {
if (!bytes) return '—'
const mb = bytes / (1024 * 1024)
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
if (mb >= 10) return `${Math.round(mb)} MB`
if (mb >= 1) return `${mb.toFixed(1)} MB`
return `${Math.round(bytes / 1024)} KB`
}
function ActiveJobsBanner({ jobs, onCancel }) {
if (!jobs || jobs.length === 0) return null
return (
<div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '0.95rem', color: '#c084fc' }}>Active System Tasks ({jobs.length})</h3>
{jobs.map((job) => (
<div key={job.id} style={{ marginBottom: 10, padding: '8px 12px', background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
<div className="row" style={{ fontSize: '0.85rem' }}>
<span className={`dot ${job.status === 'running' ? 'ok' : ''}`} />
<strong style={{ textTransform: 'capitalize' }}>{job.type}</strong>
<span className="muted">({job.status})</span>
<span className="spacer" />
<span className="mono">{job.progress}/{job.total || '—'}</span>
<button className="btn btn-ghost" style={{ padding: '2px 6px', fontSize: '0.75rem' }} onClick={() => onCancel(job.id)}>Cancel</button>
</div>
<div className="progress" style={{ margin: '6px 0' }}>
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : job.status === 'running' ? 50 : 10}%` }} />
</div>
{job.log?.length > 0 && (
<p className="hint mono" style={{ fontSize: '0.75rem', margin: 0, opacity: 0.8 }}>
{job.log[job.log.length - 1]}
</p>
)}
</div>
))}
</div>
)
}
function BatchList({ project, batches, activeJobs, onChanged, onError }) {
const [busyId, setBusyId] = useState(null)
const [selectedEngine, setSelectedEngine] = useState({})
const [engineClassMap, setEngineClassMap] = useState({})
const [expandedFilterBatchId, setExpandedFilterBatchId] = useState(null)
const hasSecondaryModel = Boolean(project?.secondary_model_path)
const model1Classes = project?.classes?.map((c) => c.name) || []
const model2Classes = project?.secondary_model_classes?.length > 0
? project.secondary_model_classes
: model1Classes
const defaultEngines = project?.base_model_path ? ['base_model'] : ['sam3']
const [selectedThreshold, setSelectedThreshold] = useState({})
async function autolabel(batch, resume = false) {
setBusyId(batch.id)
const engines = selectedEngine[batch.id] || defaultEngines
const threshold = selectedThreshold[batch.id] ?? 0.35
// Default class filter for each active engine if not customized
const currentBatchMap = engineClassMap[batch.id] || {}
const engine_classes = {}
if (engines.includes('sam3')) {
engine_classes.sam3 = currentBatchMap.sam3 || model1Classes
}
if (engines.includes('base_model')) {
engine_classes.base_model = currentBatchMap.base_model || model1Classes
}
if (engines.includes('secondary_model')) {
engine_classes.secondary_model = currentBatchMap.secondary_model || model2Classes
}
try {
await api.startAutolabel(batch.id, { resume, engines, engine_classes, threshold })
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusyId(null)
}
}
const toggleEngine = (batchId, engineKey) => {
const current = selectedEngine[batchId] || defaultEngines
let next
if (current.includes(engineKey)) {
if (current.length === 1) return // keep at least 1 engine selected
next = current.filter((e) => e !== engineKey)
} else {
next = [...current, engineKey]
}
setSelectedEngine({ ...selectedEngine, [batchId]: next })
}
const toggleEngineClass = (batchId, engineKey, className, defaultClasses) => {
const batchFilters = engineClassMap[batchId] || {}
const currentEngClasses = batchFilters[engineKey] || defaultClasses
let next
if (currentEngClasses.includes(className)) {
if (currentEngClasses.length === 1) return // keep at least 1 class
next = currentEngClasses.filter((c) => c !== className)
} else {
next = [...currentEngClasses, className]
}
setEngineClassMap({
...engineClassMap,
[batchId]: {
...batchFilters,
[engineKey]: next,
},
})
}
async function deleteBatch(batch) {
if (!window.confirm(`Delete batch "${batch.batch_label}" and all its extracted frames?`)) return
setBusyId(batch.id)
try {
await api.deleteBatch(batch.id)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusyId(null)
}
}
async function editBatch(batch) {
const newLabel = window.prompt("Enter new batch label:", batch.batch_label)
if (!newLabel || newLabel.trim() === batch.batch_label) return
setBusyId(batch.id)
try {
await api.patchBatch(batch.id, { batch_label: newLabel.trim() })
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusyId(null)
}
}
if (batches.length === 0) return null
return (
<div className="panel table-wrap" style={{ marginTop: 16 }}>
<table className="video-table">
<thead>
<tr>
<th>Batch</th><th>Range</th><th>Frames</th><th>Reviewed</th>
<th>Shapes</th><th>Model & Class Configuration</th><th>Status</th><th />
</tr>
</thead>
<tbody>
{batches.map((batch) => {
const batchJob = activeJobs?.find((j) => j.batch_id === batch.id)
const isProcessing = Boolean(batchJob || busyId === batch.id)
const activeEngines = selectedEngine[batch.id] || defaultEngines
const isFilterExpanded = expandedFilterBatchId === batch.id
const batchMap = engineClassMap[batch.id] || {}
const sam3Active = batchMap.sam3 || model1Classes
const model1Active = batchMap.base_model || model1Classes
const model2Active = batchMap.secondary_model || model2Classes
return (
<React.Fragment key={batch.id}>
<tr>
<td>
<span style={{ cursor: 'pointer', borderBottom: '1px dashed rgba(255,255,255,0.3)' }} onClick={() => editBatch(batch)} title="Click to rename batch">
{batch.date_label} · {batch.batch_label}
</span>
</td>
<td className="num">
{formatDuration(batch.start_sec)}–{formatDuration(batch.end_sec)} @ {batch.fps}fps
</td>
<td className="num">{batch.frame_count}</td>
<td className="num">{batch.reviewed}/{batch.frame_count}</td>
<td className="num">{batch.annotation_count}</td>
<td style={{ minWidth: 260 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className={`btn ${activeEngines.includes('sam3') ? 'btn-primary' : 'btn-ghost'}`}
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('sam3') ? 1 : 0.6 }}
onClick={() => toggleEngine(batch.id, 'sam3')}
disabled={isProcessing}
title="SAM3 Zero-shot Text Prompt Engine"
>
🤖 SAM3
</button>
<button
type="button"
className={`btn ${activeEngines.includes('base_model') ? 'btn-primary' : 'btn-ghost'}`}
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('base_model') ? 1 : 0.6 }}
onClick={() => toggleEngine(batch.id, 'base_model')}
disabled={isProcessing}
title="Primary Model 1 (Base / Trained)"
>
⚡ Model 1
</button>
{hasSecondaryModel && (
<button
type="button"
className={`btn ${activeEngines.includes('secondary_model') ? 'btn-primary' : 'btn-ghost'}`}
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('secondary_model') ? 1 : 0.6 }}
onClick={() => toggleEngine(batch.id, 'secondary_model')}
disabled={isProcessing}
title={project?.secondary_model_name || "Secondary Uploaded Model 2"}
>
🎯 Model 2
</button>
)}
<button
type="button"
className="btn btn-ghost"
style={{
padding: '2px 6px',
fontSize: '0.72rem',
display: 'flex',
alignItems: 'center',
gap: 3,
borderColor: isFilterExpanded ? '#c084fc' : 'rgba(255,255,255,0.2)',
color: isFilterExpanded ? '#c084fc' : '#e4e4e7',
}}
onClick={() => setExpandedFilterBatchId(isFilterExpanded ? null : batch.id)}
disabled={isProcessing}
>
⚙️ {isFilterExpanded ? 'Hide' : 'Per-Engine'}
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', fontSize: '0.72rem' }}>
<span className="muted" style={{ fontSize: '0.7rem' }}>Classes:</span>
{model1Classes.map((clsName) => {
const isModel1On = activeEngines.includes('base_model') && model1Active.includes(clsName)
const isSam3On = activeEngines.includes('sam3') && sam3Active.includes(clsName)
const isSecOn = activeEngines.includes('secondary_model') && hasSecondaryModel && model2Active.includes(clsName)
const isActiveAny = isModel1On || isSam3On || isSecOn
return (
<button
key={clsName}
type="button"
className="tag"
style={{
padding: '1px 5px',
fontSize: '0.7rem',
cursor: 'pointer',
background: isActiveAny ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
color: isActiveAny ? '#38bdf8' : '#71717a',
border: isActiveAny ? '1px solid rgba(56, 189, 248, 0.5)' : '1px solid rgba(255,255,255,0.1)',
}}
title={`Toggle ${clsName} filter`}
disabled={isProcessing}
onClick={() => {
if (activeEngines.includes('base_model')) toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)
if (activeEngines.includes('sam3')) toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)
if (activeEngines.includes('secondary_model')) toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)
}}
>
{isActiveAny ? '✓ ' : ''}{clsName}
</button>
)
})}
</div>
</div>
</td>
<td>
<span className={`tag ${batchJob ? 'info' : ''}`}>
{batchJob ? `${batchJob.type} (${batchJob.status})` : batch.status}
</span>
</td>
<td>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0}
onClick={() => autolabel(batch)}>
{batchJob?.type === 'autolabel' ? 'Processing…' : `Auto-annotate (${activeEngines.map(e => e === 'sam3' ? 'SAM3' : e === 'base_model' ? 'Model 1' : 'Model 2').join('+')})`}
</button>
{batch.annotation_count > 0 && (
<button className="btn" disabled={isProcessing || batch.frame_count === 0}
title="Skip frames that already have automatic shapes"
onClick={() => autolabel(batch, true)}>
Resume
</button>
)}
<button className="btn" disabled={batch.frame_count === 0}
onClick={() => navigate(`/projects/${project.id}/review?batch=${batch.id}`)}>
Review ({batch.reviewed}/{batch.frame_count})
</button>
<button className="btn btn-danger" disabled={isProcessing}
onClick={() => deleteBatch(batch)}>
Delete
</button>
</div>
</td>
</tr>
{/* Inline Per-Engine Class Filter Panel */}
{isFilterExpanded && (
<tr>
<td colSpan={8} style={{ background: '#09090b', padding: '12px 16px', borderBottom: '1px solid rgba(168,85,247,0.3)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ fontSize: '0.82rem', fontWeight: 600, color: '#c084fc' }}>
🛠️ Inline Per-Engine Class Filters (Select target classes per detector):
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
{/* SAM3 Section */}
{activeEngines.includes('sam3') && (
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#a855f7', marginBottom: 6 }}>
🤖 SAM3 Text Prompts
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{model1Classes.map((clsName) => {
const isActive = sam3Active.includes(clsName)
return (
<button
key={clsName}
type="button"
className="tag"
style={{
padding: '2px 6px',
fontSize: '0.72rem',
cursor: 'pointer',
background: isActive ? 'rgba(168, 85, 247, 0.3)' : 'rgba(255,255,255,0.05)',
color: isActive ? '#f3e8ff' : '#666',
border: isActive ? '1px solid rgba(168, 85, 247, 0.6)' : '1px solid transparent',
}}
onClick={() => toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)}
>
{isActive ? '✓ ' : ''}{clsName}
</button>
)
})}
</div>
</div>
)}
{/* Model 1 Section */}
{activeEngines.includes('base_model') && (
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#38bdf8', marginBottom: 6 }}>
⚡ Model 1 (Primary Base) Classes
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{model1Classes.map((clsName) => {
const isActive = model1Active.includes(clsName)
return (
<button
key={clsName}
type="button"
className="tag"
style={{
padding: '2px 6px',
fontSize: '0.72rem',
cursor: 'pointer',
background: isActive ? 'rgba(56, 189, 248, 0.3)' : 'rgba(255,255,255,0.05)',
color: isActive ? '#e0f2fe' : '#666',
border: isActive ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid transparent',
}}
onClick={() => toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)}
>
{isActive ? '✓ ' : ''}{clsName}
</button>
)
})}
</div>
</div>
)}
{/* Model 2 Section */}
{activeEngines.includes('secondary_model') && hasSecondaryModel && (
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#f43f5e', marginBottom: 6 }}>
🎯 Model 2 (Secondary Engine) Native Classes
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{model2Classes.map((clsName) => {
const isActive = model2Active.includes(clsName)
return (
<button
key={clsName}
type="button"
className="tag"
style={{
padding: '2px 6px',
fontSize: '0.72rem',
cursor: 'pointer',
background: isActive ? 'rgba(244, 63, 94, 0.3)' : 'rgba(255,255,255,0.05)',
color: isActive ? '#ffe4e6' : '#666',
border: isActive ? '1px solid rgba(244, 63, 94, 0.6)' : '1px solid transparent',
}}
onClick={() => toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)}
>
{isActive ? '✓ ' : ''}{clsName}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)
}
export default function LibraryPage({ projectId, onProject }) {
const [project, setProject] = useState(null)
const [dates, setDates] = useState([])
const [selected, setSelected] = useState(null)
const [videos, setVideos] = useState(null)
const [batches, setBatches] = useState([])
const [jobs, setJobs] = useState([])
const [error, setError] = useState('')
const loadBatches = useCallback(() => {
api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {})
}, [projectId])
const loadJobs = useCallback(() => {
api.listJobs(projectId).then((payload) => setJobs(payload.jobs)).catch(() => {})
}, [projectId])
useEffect(() => {
let cancelled = false
setError('')
Promise.all([api.getProject(projectId), api.listDates(projectId), api.listBatches(projectId), api.listJobs(projectId)])
.then(([loadedProject, library, batchPayload, jobPayload]) => {
if (cancelled) return
setProject(loadedProject)
onProject?.(loadedProject)
setDates(library.dates)
setSelected(library.dates[0]?.date ?? null)
setBatches(batchPayload.batches)
setJobs(jobPayload.jobs)
})
.catch((exc) => !cancelled && setError(exc.message))
return () => { cancelled = true }
}, [projectId])
useEffect(() => {
if (!selected) return
let cancelled = false
setVideos(null)
api.listVideos(projectId, selected)
.then((payload) => !cancelled && setVideos(payload.videos))
.catch((exc) => !cancelled && setError(exc.message))
return () => { cancelled = true }
}, [projectId, selected])
// Poll jobs every 2 seconds if there are active jobs, and refresh batches on completion
const activeJobs = jobs.filter((j) => ['queued', 'running'].includes(j.status))
const prevActiveCount = useRef(0)
useEffect(() => {
if (prevActiveCount.current > 0 && activeJobs.length === 0) {
loadBatches()
loadJobs()
}
prevActiveCount.current = activeJobs.length
if (activeJobs.length === 0) return
const timer = setInterval(() => {
loadJobs()
loadBatches()
}, 2000)
return () => clearInterval(timer)
}, [activeJobs.length, loadJobs, loadBatches])
const cancelJob = async (jobId) => {
try {
await api.cancelJob(jobId)
loadJobs()
} catch (exc) {
setError(exc.message)
}
}
if (error) {
return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
}
if (!project) return <p className="empty">Loading…</p>
return (
<>
<div className="page-head">
<div>
<h1>{project.name}</h1>
<p className="muted mono">{project.video_root}</p>
</div>
</div>
<ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} />
{batches.length > 0 && (
<>
<h2 style={{ marginBottom: 8 }}>Batches</h2>
<BatchList project={project} batches={batches} activeJobs={activeJobs} onChanged={() => { loadBatches(); loadJobs(); }} onError={setError} />
<h2 style={{ margin: '24px 0 8px' }}>Archive</h2>
</>
)}
{dates.length === 0 ? (
<p className="empty">
No date folders in this archive yet. Expected layout: &lt;date&gt;/&lt;batch&gt;.mp4
</p>
) : (
<div className="library">
<nav className="panel date-list" aria-label="Recording dates">
{dates.map((item) => (
<button
key={item.date}
className="date-item"
aria-current={item.date === selected}
onClick={() => setSelected(item.date)}
>
{item.date}
<span className="count">{item.video_count}</span>
</button>
))}
</nav>
<div className="panel table-wrap">
{videos === null ? (
<p className="empty">Reading video metadata…</p>
) : videos.length === 0 ? (
<p className="empty">No videos in {selected}.</p>
) : (
<table className="video-table">
<thead>
<tr>
<th>Batch</th>
<th>Duration</th>
<th>Resolution</th>
<th>FPS</th>
<th>Size</th>
<th>Used</th>
<th />
</tr>
</thead>
<tbody>
{videos.map((item) => (
<tr key={item.rel}>
<td>{item.batch_label}</td>
<td className="num">{formatDuration(item.duration)}</td>
<td className="num">
{item.width ? `${item.width}×${item.height}` : <span className="faint">unreadable</span>}
</td>
<td className="num">{item.fps || '—'}</td>
<td className="num">{megabytes(item.size)}</td>
<td>
{item.used_count > 0
? <span className="tag">{item.used_count} batch{item.used_count > 1 ? 'es' : ''}</span>
: <span className="faint">—</span>}
</td>
<td>
<button
className="btn"
disabled={!item.duration}
title={item.duration ? 'Pick a range and extract frames' : 'ffprobe could not read this file'}
onClick={() => navigate(`/projects/${projectId}/trim/${encodeURIComponent(item.rel)}`)}
>
Trim
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</>
)
}
+378
View File
@@ -0,0 +1,378 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { navigate } from '../App'
import { AlertIcon, CheckIcon } from '../components/Icons'
function Metric({ label, base, next, delta }) {
const better = delta != null && delta > 0
const worse = delta != null && delta < 0
return (
<tr>
<td>{label}</td>
<td className="num">{base == null ? '—' : base.toFixed(4)}</td>
<td className="num">{next.toFixed(4)}</td>
<td className={`num ${better ? 'better' : worse ? 'worse' : ''}`}>
{delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(4)}`}
</td>
</tr>
)
}
function VersionCard({ version, onPromote, onError }) {
const [busy, setBusy] = useState(false)
const metrics = version.metrics
const base = version.base_metrics
async function promote() {
setBusy(true)
try {
await api.promoteModel(version.id)
onPromote()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
return (
<div className="panel side-panel">
<div className="row">
<h2>v{version.version}</h2>
<span className="spacer" />
<span className="faint mono">
{new Date(version.created_at * 1000).toLocaleString()}
</span>
</div>
{metrics ? (
<div className="table-wrap">
<table className="video-table metrics">
<thead>
<tr><th>Metric</th><th>Base</th><th>This version</th><th>Δ</th></tr>
</thead>
<tbody>
<Metric label="mAP50" base={base?.map50} next={metrics.map50}
delta={base ? metrics.map50 - base.map50 : null} />
<Metric label="mAP50-95" base={base?.map50_95} next={metrics.map50_95}
delta={base ? metrics.map50_95 - base.map50_95 : null} />
<Metric label="precision" base={base?.precision} next={metrics.precision}
delta={base ? metrics.precision - base.precision : null} />
<Metric label="recall" base={base?.recall} next={metrics.recall}
delta={base ? metrics.recall - base.recall : null} />
</tbody>
</table>
</div>
) : <p className="hint">No metrics recorded for this version.</p>}
{!base && (
<p className="hint">
No base column: the previous model could not be scored on this val set.
</p>
)}
<div className="row">
<a className="btn" href={api.weightsUrl(version.id)} download>Download best.pt</a>
<button className="btn btn-primary" onClick={promote} disabled={busy}>
Use as base model
</button>
</div>
</div>
)
}
export default function ModelsPage({ projectId, onProject }) {
const [project, setProject] = useState(null)
const [summary, setSummary] = useState(null)
const [models, setModels] = useState([])
const [hardware, setHardware] = useState(null)
const [epochs, setEpochs] = useState(50)
const [job, setJob] = useState(null)
const [error, setError] = useState('')
const [selectedBatchIds, setSelectedBatchIds] = useState([])
const load = useCallback(async () => {
const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([
api.getProject(projectId), api.datasetSummary(projectId),
api.listModels(projectId), api.hardware(), api.listJobs(projectId),
])
setProject(loadedProject)
onProject?.(loadedProject)
setSummary(loadedSummary)
setModels(modelPayload.models)
setHardware(hw)
setSelectedBatchIds(loadedSummary.batches.map((b) => b.id))
const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status))
if (activeJob) setJob(activeJob)
}, [projectId])
useEffect(() => { load().catch((exc) => setError(exc.message)) }, [load])
useEffect(() => {
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) {
if (job?.status === 'done') load().catch(() => {})
return
}
const timer = setInterval(() => {
api.getJob(job.id).then(setJob).catch(() => {})
}, 2000)
return () => clearInterval(timer)
}, [job?.status, job?.id])
async function train() {
setError('')
try {
setJob(await api.startTraining(projectId, {
epochs: Number(epochs),
batch_ids: selectedBatchIds.length > 0 ? selectedBatchIds : null,
}))
} catch (exc) {
setError(exc.message)
}
}
const toggleBatchSelect = (id) => {
setSelectedBatchIds((prev) =>
prev.includes(id) ? prev.filter((bId) => bId !== id) : [...prev, id]
)
}
const toggleSelectAllBatches = () => {
if (selectedBatchIds.length === summary.batches.length) {
setSelectedBatchIds([])
} else {
setSelectedBatchIds(summary.batches.map((b) => b.id))
}
}
if (error && !project) return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
if (!project || !summary) return <p className="empty">Loading…</p>
const running = job && !['done', 'failed', 'cancelled'].includes(job.status)
return (
<>
<div className="page-head">
<div>
<h1>{project.name} · models</h1>
<p className="muted">
Master dataset: {summary.splits.train} train / {summary.splits.val} val
{' '}from {summary.batches.length} merged batch(es)
</p>
</div>
<span className="spacer" />
<a className="btn" href={api.datasetDownloadUrl(projectId)} download>Download dataset</a>
</div>
{error && <p className="error-banner" style={{ marginBottom: 12 }}>
<AlertIcon size={14} /> {error}
</p>}
<div className="select-engine-section">
<h2>Select Engine</h2>
<p className="hint">
Select how you want to train your model. Configure custom fine-tuning parameters or use SAM3 grounding backbone.
</p>
<div className="engine-grid">
<div className="engine-card selected">
<div className="engine-card-header">
<span className="project-badge">Selected</span>
<span className="engine-card-title">Custom Training (YOLO11)</span>
</div>
<p className="engine-card-desc">
Fine-tune on the merged master dataset using pre-configured hardware batch size and image resolution.
</p>
</div>
<div className="engine-card" title="Neural Architecture Search / Rapid Auto-train">
<div className="engine-card-header">
<span className="chip-badge info">Rapid NAS</span>
<span className="engine-card-title">Neural Architecture Search</span>
</div>
<p className="engine-card-desc">
Automated model selection optimized for latency and accuracy trade-offs on your specific project dataset.
</p>
</div>
</div>
</div>
<div className="panel side-panel" style={{ marginBottom: 20 }}>
<h2>Uploaded Models for Training & Auto-Annotation</h2>
<p className="hint">Upload up to 2 models to use for fine-tuning baseline or auto-annotating new video batches:</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16, marginTop: 12 }}>
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
<h3 style={{ fontSize: '0.9rem', color: '#c084fc', margin: '0 0 6px 0' }}>Primary Model 1 (Base Model)</h3>
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
Used as fine-tuning starting point and benchmark baseline.
</p>
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
</div>
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
<strong>Model 1 Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
</div>
<input
type="file"
accept=".pt"
id="upload-primary-model"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
await api.uploadBaseModel(projectId, file)
load()
} catch (err) {
setError(err.message)
}
}}
/>
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
Upload Primary Model 1 (.pt)
</label>
</div>
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
<h3 style={{ fontSize: '0.9rem', color: '#38bdf8', margin: '0 0 6px 0' }}>Secondary Model 2 (Auto-Annotate Engine)</h3>
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
Used as an auxiliary engine choice for fast auto-annotation.
</p>
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
Status: <strong style={{ color: '#fff' }}>{project.secondary_model_path ? (project.secondary_model_name || 'secondary_model.pt') : 'None uploaded'}</strong>
</div>
<div style={{ fontSize: '0.78rem', color: '#c084fc', marginBottom: 10 }}>
<strong>Model 2 Native Classes:</strong> {project.secondary_model_classes?.length > 0 ? project.secondary_model_classes.join(', ') : 'Extracted on upload'}
</div>
<input
type="file"
accept=".pt"
id="upload-secondary-model"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
await api.uploadSecondaryModel(projectId, file)
load()
} catch (err) {
setError(err.message)
}
}}
/>
<label htmlFor="upload-secondary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
Upload Secondary Model 2 (.pt)
</label>
</div>
</div>
</div>
<div className="review">
<div className="stack">
{models.length === 0 && !running && (
<p className="empty">No trained versions yet.</p>
)}
{models.map((version) => (
<VersionCard key={version.id} version={version} onPromote={load}
onError={setError} />
))}
</div>
<aside className="review-side stack">
<div className="panel side-panel">
<h2>Train</h2>
<p className="hint">
Fine-tunes{' '}
{project.base_model_path
? 'this project’s base model'
: `${project.base_model_fallback} (no base model uploaded)`}{' '}
on the selected dataset batches ({selectedBatchIds.length}/{summary.batches.length} selected).
</p>
<div>
<label htmlFor="epochs">Epochs</label>
<input id="epochs" type="number" min="1" max="500" value={epochs}
onChange={(e) => setEpochs(e.target.value)} />
</div>
{hardware && (
<p className="hint">
{hardware.gpu ?? 'CPU'} — defaults batch {hardware.batch},
imgsz {hardware.imgsz}. {hardware.note}
</p>
)}
<button className="btn btn-primary" onClick={train}
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0}>
{running ? 'Training…' : 'Start training'}
</button>
{summary.splits.train === 0 && (
<p className="hint">Approve and merge a batch first.</p>
)}
{selectedBatchIds.length === 0 && summary.splits.train > 0 && (
<p className="hint" style={{ color: '#ef4444' }}>Select at least one dataset batch to train.</p>
)}
</div>
{job && (
<div className="panel side-panel">
<h2>Job {job.id}</h2>
<div className="row">
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
<span>{job.status}</span>
<span className="spacer" />
<span className="mono">{job.progress}/{job.total}</span>
</div>
<div className="progress">
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
</div>
{job.error && <p className="error-banner">{job.error}</p>}
<pre className="job-log">{job.log.slice(-8).join('\n')}</pre>
{running && (
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}>
Cancel
</button>
)}
{job.status === 'done' && (
<p className="hint"><CheckIcon size={13} /> Finished — the version is listed
on the left.</p>
)}
</div>
)}
{summary.batches.length > 0 && (
<div className="panel side-panel">
<div className="row" style={{ marginBottom: 8 }}>
<h2>Select Dataset Batches</h2>
<span className="spacer" />
<button className="btn" style={{ fontSize: 11, padding: '2px 8px' }} onClick={toggleSelectAllBatches}>
{selectedBatchIds.length === summary.batches.length ? 'Deselect All' : 'Select All'}
</button>
</div>
<p className="hint">Check the approved batches you want to include in this training run:</p>
<ul className="shape-list" style={{ marginTop: 8 }}>
{summary.batches.map((item) => {
const isChecked = selectedBatchIds.includes(item.id)
return (
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleBatchSelect(item.id)}
style={{ cursor: 'pointer' }}
/>
<span className="shape-pick" style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleBatchSelect(item.id)}>
{item.date_label} · {item.batch_label}
<span className="faint mono" style={{ marginLeft: 6 }}>{item.images} img</span>
</span>
</li>
)
})}
</ul>
</div>
)}
</aside>
</div>
</>
)
}
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useRef, useState } from 'react'
import { api, classColor } from '../api'
import { navigate } from '../App'
import { AlertIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from '../components/Icons'
const BLANK_CLASS = { name: '', prompt: '' }
const CLASS_PREVIEW = 8
function NewProjectForm({ defaultRoot, onCreated, onCancel }) {
const [name, setName] = useState('')
const [labelType, setLabelType] = useState('bbox')
const [videoRoot, setVideoRoot] = useState(defaultRoot || '')
const [valEvery, setValEvery] = useState(5)
const [classes, setClasses] = useState([{ ...BLANK_CLASS }])
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const setClass = (index, patch) =>
setClasses((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
async function submit(event) {
event.preventDefault()
setBusy(true)
setError('')
try {
const created = await api.createProject({
name,
label_type: labelType,
video_root: videoRoot,
val_every: Number(valEvery),
classes: classes
.filter((row) => row.name.trim())
.map((row) => ({ name: row.name.trim(), prompt: row.prompt.trim() || null })),
})
onCreated(created)
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
return (
<form className="panel form-panel" onSubmit={submit}>
<h2>New project</h2>
<div className="field-row">
<div>
<label htmlFor="np-name">Name</label>
<input id="np-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="Sack" required autoFocus />
</div>
<div>
<label htmlFor="np-type">Label type</label>
<select id="np-type" value={labelType} onChange={(e) => setLabelType(e.target.value)}>
<option value="bbox">Bounding box (YOLO detect)</option>
<option value="polygon">Polygon (YOLO segment)</option>
</select>
<p className="hint">Fixed once the first batch is merged — every label file is written in this format.</p>
</div>
<div>
<label htmlFor="np-val">Val split — every Nth frame</label>
<input id="np-val" type="number" min="0" max="50" value={valEvery}
onChange={(e) => setValEvery(e.target.value)} />
</div>
</div>
<div>
<label htmlFor="np-root">Video archive root</label>
<input id="np-root" value={videoRoot} onChange={(e) => setVideoRoot(e.target.value)}
placeholder="/videos" required />
<p className="hint">Laid out as &lt;date&gt;/&lt;batch&gt;.mp4. Read-only — nothing is written here.</p>
</div>
<div>
<label>Classes</label>
<div className="class-rows">
{classes.map((row, index) => (
<div className="class-row" key={index}>
<span className="swatch" style={{ background: classColor(index) }} />
<input value={row.name} onChange={(e) => setClass(index, { name: e.target.value })}
placeholder={`class ${index}`} aria-label={`Class ${index} name`} />
<input value={row.prompt} onChange={(e) => setClass(index, { prompt: e.target.value })}
placeholder="SAM3 prompt (defaults to the name)"
aria-label={`Class ${index} prompt`} />
<button type="button" className="btn btn-danger"
aria-label={`Remove class ${index}`}
disabled={classes.length === 1}
onClick={() => setClasses((rows) => rows.filter((_, i) => i !== index))}>
<TrashIcon size={14} />
</button>
</div>
))}
</div>
<p className="hint">
Upload a base model afterwards to replace these with the model&apos;s own classes.
</p>
</div>
{error && <p className="error-banner"><AlertIcon size={14} /> {error}</p>}
<div className="form-actions">
<button className="btn btn-primary" type="submit" disabled={busy}>
{busy ? 'Creating…' : 'Create project'}
</button>
<button className="btn btn-ghost" type="button" onClick={onCancel}>Cancel</button>
<span className="spacer" />
<button className="btn" type="button"
onClick={() => setClasses((rows) => [...rows, { ...BLANK_CLASS }])}>
<PlusIcon size={14} /> Add class
</button>
</div>
</form>
)
}
function ProjectCard({ project, onChanged, onError }) {
const fileInputModel1 = useRef(null)
const fileInputModel2 = useRef(null)
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const [addingClass, setAddingClass] = useState(false)
const [newClassName, setNewClassName] = useState('')
const [newClassPrompt, setNewClassPrompt] = useState('')
async function submitAddClass(e) {
e.preventDefault()
if (!newClassName.trim()) return
setBusy(true)
try {
await api.addClass(project.id, { name: newClassName.trim(), prompt: newClassPrompt.trim() || undefined })
setNewClassName('')
setNewClassPrompt('')
setAddingClass(false)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
async function removeClass(item) {
const merged = project.dataset.train + project.dataset.val
const consequences = [
item.annotation_count > 0 && `${item.annotation_count} shape(s) will be deleted`,
merged > 0 && `${merged} label file(s) in the master dataset will be rewritten`,
'the classes above it will be renumbered',
].filter(Boolean).join(', ')
if (!window.confirm(
`Delete the class "${item.name}"?\n\n${consequences}.\n\nThis cannot be undone.`
)) return
setBusy(true)
try {
await api.deleteClass(project.id, item.class_id)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
async function uploadModel1(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
setBusy(true)
try {
await api.uploadBaseModel(project.id, file)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
async function uploadModel2(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
setBusy(true)
try {
await api.uploadSecondaryModel(project.id, file)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
async function remove() {
if (!window.confirm(`Delete "${project.name}" and everything under it?`)) return
setBusy(true)
try {
await api.deleteProject(project.id)
onChanged()
} catch (exc) {
onError(exc.message)
} finally {
setBusy(false)
}
}
const merged = project.dataset.train + project.dataset.val
const insertedCount = [project.base_model_path, project.secondary_model_path].filter(Boolean).length
return (
<div className="panel project-card">
<div className="title-row">
<h2>{project.name}</h2>
<span className="tag" title={project.label_type_locked ? "Locked: batches have already been merged in this format" : undefined}>
{project.label_type_locked ? `locked: ${project.label_type}` : project.label_type}
</span>
</div>
<dl>
<dt>Base models</dt>
<dd>
<strong>{insertedCount} model(s) inserted</strong>
<span className="muted" style={{ fontSize: '0.8rem', display: 'block', marginTop: 2 }}>
{insertedCount === 0 && `none — default (${project.base_model_fallback})`}
{insertedCount === 1 && (project.base_model_path ? 'Model 1 (Primary)' : 'Model 2 (Secondary)')}
{insertedCount === 2 && 'Model 1 (Primary) & Model 2 (Secondary)'}
</span>
</dd>
<dt>Archive</dt>
<dd className="mono">{project.video_root}</dd>
<dt>Batches</dt>
<dd>{project.batch_count}</dd>
<dt>Dataset</dt>
<dd>{merged ? `${project.dataset.train} train / ${project.dataset.val} val` : 'empty'}</dd>
</dl>
{/* A base model can carry 80 classes; showing them all buries the card,
but they all have to be reachable to be deletable. */}
<div className="classes">
{(expanded ? project.classes : project.classes.slice(0, CLASS_PREVIEW)).map((item) => (
<span className="tag class-tag" key={item.class_id}
title={`prompt: ${item.prompt}`}>
<span className="swatch" style={{ background: classColor(item.class_id) }} />
{item.name}
{item.annotation_count > 0 && (
<span className="faint mono">{item.annotation_count}</span>
)}
{project.classes.length > 1 && (
<button className="chip-x" disabled={busy}
aria-label={`Delete class ${item.name}`}
onClick={() => removeClass(item)}>
<XIcon size={11} />
</button>
)}
</span>
))}
{project.classes.length > CLASS_PREVIEW && (
<button className="tag" onClick={() => setExpanded((v) => !v)}>
{expanded ? 'show fewer' : `+${project.classes.length - CLASS_PREVIEW} more`}
</button>
)}
<button className="tag" onClick={() => setAddingClass((v) => !v)} title="Add a new class to this project">
<PlusIcon size={11} /> {addingClass ? 'cancel' : 'add class'}
</button>
</div>
{addingClass && (
<form className="add-class-inline" onSubmit={submitAddClass} style={{ display: 'flex', gap: 6, margin: '8px 0', alignItems: 'center' }}>
<input
type="text"
placeholder="Class name (e.g. half-sack)"
value={newClassName}
onChange={(e) => setNewClassName(e.target.value)}
required
style={{ flex: 1, padding: '4px 8px', fontSize: '0.85rem' }}
/>
<input
type="text"
placeholder="SAM3 Prompt (optional)"
value={newClassPrompt}
onChange={(e) => setNewClassPrompt(e.target.value)}
style={{ flex: 1, padding: '4px 8px', fontSize: '0.85rem' }}
/>
<button className="btn btn-primary" type="submit" disabled={busy || !newClassName.trim()} style={{ padding: '4px 10px', fontSize: '0.85rem' }}>
Save
</button>
</form>
)}
<div className="actions" style={{ flexWrap: 'wrap', gap: 6 }}>
<button className="btn btn-primary" onClick={() => navigate(`/projects/${project.id}`)}>
Open
</button>
<button className="btn" onClick={() => fileInputModel1.current?.click()} disabled={busy} title="Upload Primary Base Model 1 (.pt)">
<UploadIcon size={14} /> Model 1
</button>
<input ref={fileInputModel1} type="file" accept=".pt" hidden onChange={uploadModel1} />
<button className="btn" onClick={() => fileInputModel2.current?.click()} disabled={busy} title="Upload Secondary Auto-Annotate Model 2 (.pt)">
<UploadIcon size={14} /> Model 2
</button>
<input ref={fileInputModel2} type="file" accept=".pt" hidden onChange={uploadModel2} />
<span className="spacer" />
<button className="btn btn-danger" onClick={remove} disabled={busy}
aria-label={`Delete ${project.name}`}>
<TrashIcon size={14} />
</button>
</div>
</div>
)
}
export default function ProjectsPage() {
const [projects, setProjects] = useState(null)
const [defaultRoot, setDefaultRoot] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
async function load() {
try {
const payload = await api.listProjects()
setProjects(payload.projects)
setDefaultRoot(payload.video_root_default)
} catch (exc) {
setError(exc.message)
setProjects([])
}
}
useEffect(() => { load() }, [])
return (
<>
<div className="page-head">
<div>
<h1>Projects</h1>
<p className="muted">
One project per model you are improving: its base weights, its classes, its dataset.
</p>
</div>
<span className="spacer" />
{!creating && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<PlusIcon size={14} /> New project
</button>
)}
</div>
{error && <p className="error-banner" style={{ marginBottom: 14 }}><AlertIcon size={14} /> {error}</p>}
{creating && (
<div style={{ marginBottom: 18 }}>
<NewProjectForm
defaultRoot={defaultRoot}
onCancel={() => setCreating(false)}
onCreated={() => { setCreating(false); setError(''); load() }}
/>
</div>
)}
{projects === null && <p className="empty">Loading…</p>}
{projects?.length === 0 && !creating && (
<p className="empty">No projects yet. Create one to start enriching a dataset.</p>
)}
<div className="card-grid">
{projects?.map((project) => (
<ProjectCard key={project.id} project={project} onChanged={load} onError={setError} />
))}
</div>
</>
)
}
+338
View File
@@ -0,0 +1,338 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { navigate } from '../App'
import AnnotationCanvas from '../components/AnnotationCanvas'
import Filmstrip from '../components/Filmstrip'
import { AlertIcon, CheckIcon, XIcon } from '../components/Icons'
import QuickReclassBar from '../components/QuickReclassBar'
import ReviewSidebar from '../components/ReviewSidebar'
export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) {
const [batch, setBatch] = useState(null)
const [project, setProject] = useState(null)
const [frames, setFrames] = useState([])
const [index, setIndex] = useState(0)
const [annotations, setAnnotations] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [activeClass, setActiveClass] = useState(0)
const [assistMode, setAssistMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [activeJob, setActiveJob] = useState(null)
const batchId = batch?.id || rawBatchId
const stripRef = useRef(null)
const hasInitialAutoJump = useRef(false)
const frame = frames[index]
const reload = useCallback(async () => {
let targetBatchId = rawBatchId
if (!targetBatchId && projectId) {
const bList = await api.listBatches(projectId)
if (bList?.batches?.length > 0) targetBatchId = bList.batches[0].id
}
if (!targetBatchId) throw new Error('No batch found to review')
const loaded = await api.getBatch(targetBatchId)
setBatch(loaded)
const payload = await api.listFrames(targetBatchId)
setFrames(payload.frames)
if (!hasInitialAutoJump.current && payload.frames?.length > 0) {
hasInitialAutoJump.current = true
const firstAnnotated = payload.frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstAnnotated > 0) setIndex(firstAnnotated)
}
const jobsPayload = await api.listJobs(loaded.project_id).catch(() => ({ jobs: [] }))
const currentJob = jobsPayload.jobs?.find(
(j) => j.batch_id === Number(targetBatchId) && ['queued', 'running'].includes(j.status)
)
setActiveJob(currentJob || null)
return loaded
}, [rawBatchId, projectId])
useEffect(() => {
reload().then((loaded) => api.getProject(loaded.project_id)).then((loadedProject) => {
setProject(loadedProject)
onProject?.(loadedProject)
}).catch((exc) => setError(exc.message))
}, [rawBatchId, projectId, reload])
const prevJobId = useRef(activeJob?.id)
useEffect(() => {
if (prevJobId.current && !activeJob) {
hasInitialAutoJump.current = false
reload().catch(() => {})
}
prevJobId.current = activeJob?.id
if (!activeJob) return
const timer = setInterval(() => {
reload().catch(() => {})
if (frame) api.frameAnnotations(frame.id).then((p) => setAnnotations(p.annotations)).catch(() => {})
}, 2000)
return () => clearInterval(timer)
}, [activeJob, reload, frame?.id])
useEffect(() => {
if (!frame) return
let cancelled = false
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
setSelectedId(null)
return () => { cancelled = true }
}, [frame?.id])
useEffect(() => {
const active = stripRef.current?.querySelector('[aria-current="true"]')
active?.scrollIntoView({ block: 'nearest', inline: 'center' })
}, [index])
function patchFrameLocally(frameId, patch) {
setFrames((rows) => rows.map((row) => (row.id === frameId ? { ...row, ...patch } : row)))
}
const setStatus = useCallback(async (status) => {
if (!frame) return
patchFrameLocally(frame.id, { review_status: status })
try {
await api.setFrameStatus(frame.id, status)
setBatch(await api.getBatch(batchId))
} catch (exc) { setError(exc.message) }
setIndex((current) => Math.min(current + 1, frames.length - 1))
}, [frame, frames.length, batchId])
async function createShape(geometry) {
try {
const created = await api.addAnnotation(frame.id, { class_id: activeClass, geometry })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 })
} catch (exc) { setError(exc.message) }
}
async function assist(box) {
setBusy(true); setError('')
try {
const created = await api.assist(frame.id, { box, class_id: activeClass })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
}
async function updateShape(id, geometry, { local, commit } = {}) {
if (local && geometry) {
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry } : row)))
return
}
if (!commit) return
const current = annotations.find((row) => row.id === id)
if (!current) return
try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) }
}
const removeSelected = useCallback(async () => {
if (selectedId == null || !frame) return
const id = selectedId
setAnnotations((rows) => rows.filter((row) => row.id !== id))
setSelectedId(null)
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row)))
try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) }
}, [selectedId, frame])
const reclass = useCallback(async (classId) => {
setActiveClass(classId)
if (selectedId == null) return
try {
const updated = await api.patchAnnotation(selectedId, { class_id: classId })
setAnnotations((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
} catch (exc) { setError(exc.message) }
}, [selectedId])
async function approveBatch() {
try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) }
}
async function jumpToPending() {
try {
const { frame_id: frameId } = await api.nextPending(batchId, frame?.idx ?? -1)
const position = frames.findIndex((row) => row.id === frameId)
if (position >= 0) setIndex(position)
} catch (exc) { setError(exc.message) }
}
const jumpToNextAnnotated = useCallback(() => {
if (!frames?.length) return
const nextIdx = frames.findIndex((f, idx) => idx > index && (f.annotation_count ?? 0) > 0)
if (nextIdx >= 0) setIndex(nextIdx)
else {
const firstIdx = frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstIdx >= 0) setIndex(firstIdx)
}
}, [frames, index])
const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode }
useEffect(() => {
function onKeyDown(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } = stateRef.current
const key = event.key
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N'].includes(key)
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
if (key === 's' || key === 'S') setAssistMode?.(true)
else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1))
else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1))
else if (key === 'a' || key === 'A') setStatus?.('approved')
else if (key === 'x' || key === 'X') setStatus?.('rejected')
else if (key === 'u' || key === 'U') jumpToPending?.()
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) {
const classId = Number(key) - 1
if (project && classId < project.classes.length) reclass?.(classId)
}
}
function onKeyUp(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
if (event.key === 's' || event.key === 'S') { event.preventDefault(); event.stopPropagation(); stateRef.current.setAssistMode?.(false) }
}
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('keyup', onKeyUp, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('keyup', onKeyUp, true)
}
}, [])
if (error && !batch) return <p className="error-banner" style={{ margin: 20 }}><AlertIcon size={14} /> {error}</p>
if (!batch || !project) return <p className="empty" style={{ margin: 20, color: 'var(--text-muted)' }}>Loading batch data…</p>
const reviewed = (batch.review?.approved ?? 0) + (batch.review?.rejected ?? 0)
const classesList = project.classes ?? []
async function approveAllFrames() {
if (!window.confirm(`Mark all ${batch.review?.pending ?? 0} pending frames as approved?`)) return
try { await api.approveAllBatchFrames(batchId); await reload() } catch (exc) { setError(exc.message) }
}
async function clearClassInBatch(item) {
if (!window.confirm(`Clear all shapes of class "${item.name}" across ALL frames in this batch?`)) return
try {
await api.clearBatchClassAnnotations(batchId, item.class_id)
await reload()
if (frame) {
const payload = await api.frameAnnotations(frame.id)
setAnnotations(payload.annotations)
}
} catch (exc) { setError(exc.message) }
}
return (
<>
<div className="page-head">
<div>
<h1>{batch.date_label} · {batch.batch_label}</h1>
<p className="muted">
{batch.frame_count} frames · {reviewed}/{batch.frame_count} reviewed ·
{' '}<strong style={{ color: batch.annotation_count > 0 ? '#c084fc' : '#a1a1aa' }}>{batch.annotation_count} shapes</strong> · status {batch.status}
</p>
</div>
<span className="spacer" />
{(batch.review?.pending ?? 0) > 0 && batch.status !== 'merged' && (
<button className="btn" style={{ marginRight: 8 }} title="Mark all pending frames in this batch as approved" onClick={approveAllFrames}>
Approve All Frames ({batch.review?.pending})
</button>
)}
<button
className="btn btn-primary"
disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'}
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Merge the approved frames into the master dataset'}
onClick={approveBatch}
>
{batch.status === 'merged' ? 'Merged' : 'Approve batch'}
</button>
</div>
{activeJob && (
<div className="panel side-panel" style={{ marginBottom: 12, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<div className="row" style={{ fontSize: '0.85rem' }}>
<span className="dot ok" />
<strong style={{ textTransform: 'capitalize' }}>Auto-labeling in progress…</strong>
<span className="spacer" />
<span className="mono">{activeJob.progress}/{activeJob.total || '—'} frames</span>
</div>
<div className="progress" style={{ margin: '6px 0' }}>
<span style={{ width: `${activeJob.total ? (activeJob.progress / activeJob.total) * 100 : 50}%` }} />
</div>
</div>
)}
{error && <p className="error-banner" style={{ marginBottom: 12 }}><AlertIcon size={14} /> {error}</p>}
<div className="review">
<div className="review-main">
{frame && (
<AnnotationCanvas
frame={frame}
imageUrl={api.frameUrl(frame.id)}
annotations={annotations}
selectedId={selectedId}
activeClass={activeClass}
assistMode={assistMode}
classes={classesList}
onSelect={setSelectedId}
onCreate={createShape}
onUpdate={updateShape}
onAssist={assist}
/>
)}
<div className="frame-bar">
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
<span className="mono">
{index + 1} / {frames.length}
{frame && <span className={`status-pill ${frame.review_status}`}>{frame.review_status}</span>}
</span>
<button className="btn" onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))} disabled={index >= frames.length - 1}>→</button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
onClick={jumpToNextAnnotated}
title="Jump to next frame with annotations [N]"
>
🏷️ Next Shape [N]
</button>
<span className="spacer" />
{busy && <span className="muted">asking SAM3…</span>}
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
<button className="btn btn-primary" onClick={() => setStatus('approved')}><CheckIcon size={14} /> Approve [A]</button>
</div>
{selectedId != null && (
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
)}
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
</div>
<ReviewSidebar
classesList={classesList}
activeClass={activeClass}
reclass={reclass}
clearClassInBatch={clearClassInBatch}
annotations={annotations}
selectedId={selectedId}
setSelectedId={setSelectedId}
removeSelected={removeSelected}
project={project}
jumpToNextAnnotated={jumpToNextAnnotated}
batchAnnotationCount={batch?.annotation_count ?? 0}
/>
</div>
</>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from 'react'
import { api, formatDuration } from '../api'
import { navigate } from '../App'
import { AlertIcon, CheckIcon } from '../components/Icons'
function timecode(seconds) {
const total = Math.max(0, seconds || 0)
const m = Math.floor(total / 60)
const s = Math.floor(total % 60)
const cs = Math.floor((total % 1) * 10)
return `${m}:${String(s).padStart(2, '0')}.${cs}`
}
function parseTimecode(text, fallback) {
const trimmed = String(text).trim()
if (!trimmed) return fallback
const parts = trimmed.split(':').map(Number)
if (parts.some(Number.isNaN)) return fallback
return parts.length === 1 ? parts[0] : parts[0] * 60 + parts[1]
}
export default function TrimPage({ projectId, rel }) {
const videoRef = useRef(null)
const [info, setInfo] = useState(null)
const [error, setError] = useState('')
const [start, setStart] = useState(0)
const [end, setEnd] = useState(0)
const [fps, setFps] = useState(1)
const [playhead, setPlayhead] = useState(0)
const [job, setJob] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
api.videoInfo(projectId, rel)
.then((payload) => {
setInfo(payload)
setEnd(Math.min(payload.duration, 60))
})
.catch((exc) => setError(exc.message))
}, [projectId, rel])
// Poll the extraction job until it stops moving.
useEffect(() => {
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) return
const timer = setInterval(() => {
api.getJob(job.id).then(setJob).catch(() => {})
}, 1000)
return () => clearInterval(timer)
}, [job])
const duration = info?.duration || 0
const estimated = Math.max(0, Math.round((end - start) * fps))
function seek(seconds) {
if (videoRef.current) videoRef.current.currentTime = seconds
}
async function extract() {
setBusy(true)
setError('')
try {
const batch = await api.createBatch(projectId, {
rel, start_sec: start, end_sec: end, fps: Number(fps),
})
const payload = await api.listJobs(projectId)
setJob(payload.jobs.find((item) => item.batch_id === batch.id) ?? null)
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
if (error && !info) return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
if (!info) return <p className="empty">Reading the video…</p>
const finished = job?.status === 'done'
return (
<>
<div className="page-head">
<div>
<h1>{info.date_label} · {info.batch_label}</h1>
<p className="muted">
{formatDuration(duration)} · {info.width}×{info.height} · {info.fps} fps source
</p>
</div>
<span className="spacer" />
<button className="btn btn-ghost" onClick={() => navigate(`/projects/${projectId}`)}>
Back to library
</button>
</div>
<div className="trim">
<div className="panel trim-player">
<video
ref={videoRef}
src={api.videoUrl(projectId, rel)}
controls
preload="metadata"
onTimeUpdate={(e) => setPlayhead(e.target.currentTime)}
/>
</div>
<div className="panel trim-controls stack">
<h2>Range</h2>
<div className="range-group">
<label htmlFor="trim-start">Start — {timecode(start)}</label>
<input
id="trim-start" type="range" min="0" max={duration} step="0.1" value={start}
onChange={(e) => {
const value = Math.min(Number(e.target.value), end - 0.1)
setStart(value)
seek(value)
}}
/>
<div className="row">
<input
aria-label="Start timecode" value={timecode(start)}
onChange={(e) => setStart(Math.min(parseTimecode(e.target.value, start), end - 0.1))}
/>
<button className="btn" onClick={() => setStart(Math.min(playhead, end - 0.1))}>
Use playhead
</button>
</div>
</div>
<div className="range-group">
<label htmlFor="trim-end">End — {timecode(end)}</label>
<input
id="trim-end" type="range" min="0" max={duration} step="0.1" value={end}
onChange={(e) => {
const value = Math.max(Number(e.target.value), start + 0.1)
setEnd(value)
seek(value)
}}
/>
<div className="row">
<input
aria-label="End timecode" value={timecode(end)}
onChange={(e) => setEnd(Math.max(parseTimecode(e.target.value, end), start + 0.1))}
/>
<button className="btn" onClick={() => setEnd(Math.max(playhead, start + 0.1))}>
Use playhead
</button>
</div>
</div>
<div>
<label htmlFor="trim-fps">Frames per second</label>
<input
id="trim-fps" type="number" min="0.1" max="30" step="0.1" value={fps}
onChange={(e) => setFps(e.target.value)}
/>
</div>
<p className="hint">
{formatDuration(end - start)} of video → <strong>{estimated}</strong> frame(s)
</p>
{error && <p className="error-banner"><AlertIcon size={14} /> {error}</p>}
{job && (
<div className="job-status">
<div className="row">
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
<span>{job.status}</span>
<span className="spacer" />
<span className="mono">{job.progress}/{job.total || estimated}</span>
</div>
<div className="progress">
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
</div>
{job.error && <p className="error-banner">{job.error}</p>}
{job.message && <p className="hint">{job.message}</p>}
</div>
)}
<div className="form-actions">
<button className="btn btn-primary" onClick={extract}
disabled={busy || estimated === 0 || (job && !['done', 'failed', 'cancelled'].includes(job.status))}>
{finished ? <><CheckIcon size={14} /> Extracted</> : 'Extract frames'}
</button>
{job && !['done', 'failed', 'cancelled'].includes(job.status) && (
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}>
Cancel
</button>
)}
</div>
</div>
</div>
</>
)
}
+266
View File
@@ -0,0 +1,266 @@
/* frontend/src/roboflow.css */
.roboflow-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg, #0b0f19);
color: var(--text, #f3f4f6);
}
.roboflow-sidebar {
width: 260px;
background: rgba(17, 24, 39, 0.65);
border-right: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
padding: 16px 0;
flex-shrink: 0;
transition: width 0.2s ease, padding 0.2s ease;
}
.roboflow-sidebar.collapsed {
width: 64px;
}
.sidebar-header {
padding: 0 20px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.roboflow-sidebar.collapsed .sidebar-header {
padding: 0 12px;
justify-content: center;
}
.sidebar-collapse-btn {
background: none;
border: none;
color: var(--text-faint, #9ca3af);
cursor: pointer;
padding: 4px;
font-size: 12px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.sidebar-collapse-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
}
.sidebar-logo {
font-weight: 700;
font-size: 16px;
color: var(--text);
white-space: nowrap;
}
.roboflow-sidebar.collapsed .sidebar-item {
padding: 12px 0;
text-align: center;
display: flex;
justify-content: center;
}
.sidebar-icon {
font-size: 16px;
margin-right: 8px;
}
.roboflow-sidebar.collapsed .sidebar-icon {
margin-right: 0;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section-title {
padding: 0 20px;
font-size: 11px;
font-weight: 600;
color: var(--text-faint, #9ca3af);
margin-bottom: 8px;
letter-spacing: 0.05em;
}
.sidebar-item {
display: flex;
align-items: center;
padding: 8px 20px;
color: var(--text-muted, #d1d5db);
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidebar-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.sidebar-item.active {
background: rgba(168, 85, 247, 0.15);
color: var(--text);
border-right: 3px solid #a855f7;
font-weight: 500;
}
.sidebar-spacer {
flex: 1;
}
.sidebar-footer {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sidebar-theme-toggle {
background: none;
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-muted);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.sidebar-theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.sidebar-health {
background: rgba(0, 0, 0, 0.25);
border-radius: 6px;
padding: 10px;
font-size: 11px;
color: var(--text-faint);
display: grid;
gap: 4px;
}
.health-item {
display: flex;
justify-content: space-between;
}
.roboflow-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Models page engine cards */
.select-engine-section {
margin-bottom: 32px;
}
.engine-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.engine-card {
background: rgba(17, 24, 39, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s;
}
.engine-card:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.engine-card.selected {
border-color: #a855f7;
background: rgba(168, 85, 247, 0.05);
}
.engine-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.engine-card-title {
font-weight: 600;
font-size: 15px;
}
.project-badge, .chip-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.project-badge {
background: rgba(168, 85, 247, 0.2);
color: #c084fc;
}
.chip-badge.info {
background: rgba(56, 189, 248, 0.2);
color: #38bdf8;
}
.engine-card-desc {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
/* Light mode overrides */
[data-theme='light'] {
--bg: #f3f4f6;
--text: #111827;
--text-muted: #4b5563;
--text-faint: #6b7280;
}
[data-theme='light'] .roboflow-sidebar {
background: #ffffff;
border-right-color: #e5e7eb;
}
[data-theme='light'] .sidebar-item:hover {
background: #f9fafb;
}
[data-theme='light'] .sidebar-item.active {
background: #faf5ff;
}
[data-theme='light'] .engine-card {
background: #ffffff;
border-color: #e5e7eb;
}
[data-theme='light'] .engine-card:hover {
border-color: #d1d5db;
}
[data-theme='light'] .engine-card.selected {
border-color: #a855f7;
background: #faf5ff;
}
[data-theme='light'] .sidebar-health {
background: #f3f4f6;
}
+255
View File
@@ -0,0 +1,255 @@
html, body, #root {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden !important;
background-color: #0b0f19;
}
:root {
color-scheme: dark;
--bg: #0b0f19;
--panel: rgba(17, 24, 39, 0.5);
--panel-raised: rgba(30, 41, 59, 0.7);
--border: rgba(255, 255, 255, 0.12);
--border-strong: rgba(168, 85, 247, 0.45);
--text: #f3f4f6;
--text-muted: #9ca3af;
--text-faint: #6b7280;
--accent: #a855f7;
--accent-hover: #c084fc;
--accent-contrast: #ffffff;
--accent-soft: rgba(168, 85, 247, 0.22);
--ok: #10b981;
--warn: #f59e0b;
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.2);
/* Class colours */
--class-0: #f59e0b;
--class-1: #38bdf8;
--class-2: #10b981;
--class-3: #facc15;
--class-4: #6366f1;
--class-5: #f97316;
--class-6: #ec4899;
--class-7: #9ca3af;
--radius: 12px;
--radius-sm: 8px;
--space: 8px;
--font: 'Inter', system-ui, -apple-system, sans-serif;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--transition: 160ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
* {
box-sizing: border-box;
}
/* Custom HUD Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 { font-size: 20px; }
h2 { font-size: 16px; }
h3 { font-size: 14px; }
a {
color: var(--accent);
text-decoration: none;
}
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--mono); font-size: 12px; }
/* Every interactive element gets a pointer and a visible focus ring — the
* review screens have to be usable without a mouse at all. */
button, [role="button"], summary, label.clickable {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--panel-raised);
color: var(--text);
font: inherit;
font-weight: 500;
transition: background var(--transition), border-color var(--transition),
color var(--transition);
}
.btn:hover:not(:disabled) {
background: var(--border);
border-color: var(--text-faint);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-danger {
color: var(--danger);
border-color: transparent;
background: transparent;
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-soft);
border-color: var(--danger);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--panel-raised);
color: var(--text);
}
input, select, textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
transition: border-color var(--transition), box-shadow var(--transition);
}
input:hover, select:hover, textarea:hover { border-color: var(--text-faint); }
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
select { cursor: pointer; }
label {
display: block;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.row {
display: flex;
align-items: center;
gap: calc(var(--space) * 1.5);
}
.stack {
display: flex;
flex-direction: column;
gap: calc(var(--space) * 1.5);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
background: var(--panel-raised);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
.swatch {
width: 9px;
height: 9px;
border-radius: 2px;
flex: none;
}
.error-banner {
padding: 9px 12px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--text);
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// In dev the SPA runs on 5173 and the API on 8000; this proxy keeps every fetch
// same-origin, so the code is identical to how nginx serves it in Docker.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.API_URL || 'http://localhost:8000',
changeOrigin: true,
},
},
},
})