fragmenta / app /frontend /src /components /BulkAnnotatePanel.js
MazCodes's picture
Upload folder using huggingface_hub
9426218 verified
raw
history blame
22.7 kB
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box, Paper, Typography, TextField, Button, MenuItem, Select, FormControl,
InputLabel, LinearProgress, Alert, Table, TableHead, TableRow, TableCell,
TableBody, TableContainer, Checkbox, Tooltip, CircularProgress,
Accordion, AccordionSummary, AccordionDetails, Autocomplete, Chip,
} from '@mui/material';
import {
Tags as TagsIcon,
CloudDownload as CloudDownloadIcon,
Save as SaveIcon,
FolderOpen as FolderOpenIcon,
Upload as UploadIcon,
ChevronDown as ExpandMoreIcon,
RotateCcw as ResetIcon,
} from 'lucide-react';
import api from '../api';
const POLL_INTERVAL_MS = 800;
export default function BulkAnnotatePanel({ onCommitted }) {
const [folderPath, setFolderPath] = useState('');
const [tier, setTier] = useState('basic');
const [status, setStatus] = useState(null);
const [results, setResults] = useState([]);
const [selected, setSelected] = useState({});
const [copyFiles, setCopyFiles] = useState(true);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [committing, setCommitting] = useState(false);
const [isDocker, setIsDocker] = useState(false);
const [uploading, setUploading] = useState(false);
const [labels, setLabels] = useState({ genre: [], mood: [], instruments: [] });
const [labelsOverridden, setLabelsOverridden] = useState(false);
const [labelsLoading, setLabelsLoading] = useState(false);
const [labelsSaving, setLabelsSaving] = useState(false);
const [labelsMessage, setLabelsMessage] = useState('');
const [labelsError, setLabelsError] = useState('');
const pollRef = useRef(null);
const folderInputRef = useRef(null);
useEffect(() => {
api.get('/api/environment')
.then(({ data }) => setIsDocker(!!data?.docker))
.catch(() => {});
}, []);
const loadLabels = useCallback(async () => {
setLabelsLoading(true);
try {
const { data } = await api.get('/api/annotator-labels');
setLabels({
genre: data?.labels?.genre || [],
mood: data?.labels?.mood || [],
instruments: data?.labels?.instruments || [],
});
setLabelsOverridden(!!data?.overridden);
setLabelsError('');
} catch (exc) {
setLabelsError(exc.response?.data?.error || exc.message);
} finally {
setLabelsLoading(false);
}
}, []);
useEffect(() => { loadLabels(); }, [loadLabels]);
const updateLabelCategory = (category, value) => {
const cleaned = Array.from(new Set(
(value || []).map((s) => String(s).trim()).filter(Boolean)
));
setLabels((prev) => ({ ...prev, [category]: cleaned }));
setLabelsMessage('');
};
const saveLabels = async () => {
setLabelsSaving(true);
setLabelsMessage('');
setLabelsError('');
try {
const { data } = await api.put('/api/annotator-labels', labels);
setLabels(data?.labels || labels);
setLabelsOverridden(!!data?.overridden);
setLabelsMessage('Annotator labels saved.');
} catch (exc) {
setLabelsError(exc.response?.data?.error || exc.message);
} finally {
setLabelsSaving(false);
}
};
const resetLabels = async () => {
setLabelsSaving(true);
setLabelsMessage('');
setLabelsError('');
try {
const { data } = await api.delete('/api/annotator-labels');
setLabels(data?.labels || { genre: [], mood: [], instruments: [] });
setLabelsOverridden(false);
setLabelsMessage('Reverted to built-in default labels.');
} catch (exc) {
setLabelsError(exc.response?.data?.error || exc.message);
} finally {
setLabelsSaving(false);
}
};
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const fetchStatus = useCallback(async () => {
let data;
try {
const resp = await api.get('/api/bulk-annotate/status');
data = resp.data;
} catch (exc) {
return;
}
setStatus(data);
const annotationState = data.state;
const downloadState = data.clap_download?.state;
if (annotationState === 'done') {
try {
const resp = await api.get('/api/bulk-annotate/results');
setResults(resp.data.results || []);
const sel = {};
(resp.data.results || []).forEach((r, i) => { sel[i] = !r.error; });
setSelected(sel);
} catch {
return;
}
} else if (annotationState === 'error') {
setError(data.error || 'Annotation failed.');
}
const annotationInactive = annotationState !== 'running';
const downloadInactive = downloadState !== 'running';
if (annotationInactive && downloadInactive) {
stopPolling();
}
}, [stopPolling]);
const startPolling = useCallback(() => {
stopPolling();
pollRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS);
}, [fetchStatus, stopPolling]);
useEffect(() => {
fetchStatus();
return () => stopPolling();
}, [fetchStatus, stopPolling]);
const startAnnotation = async () => {
setError('');
setMessage('');
setResults([]);
try {
await api.post('/api/bulk-annotate', { folder_path: folderPath, tier });
startPolling();
} catch (exc) {
setError(exc.response?.data?.error || exc.message);
}
};
const pickFolder = async () => {
setError('');
try {
const { data } = await api.post('/api/pick-folder', { start_dir: folderPath || undefined });
if (data?.path) setFolderPath(data.path);
} catch (exc) {
setError(exc.response?.data?.error || exc.message);
}
};
const openFolderUpload = () => {
setError('');
if (folderInputRef.current) {
folderInputRef.current.value = '';
folderInputRef.current.click();
}
};
const handleFolderSelected = async (event) => {
const fileList = Array.from(event.target.files || []);
if (fileList.length === 0) return;
setError('');
setUploading(true);
try {
const form = new FormData();
fileList.forEach((file) => {
form.append('files', file);
form.append('rel_paths', file.webkitRelativePath || file.name);
});
const { data } = await api.post('/api/upload-folder', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (data?.path) setFolderPath(data.path);
} catch (exc) {
setError(exc.response?.data?.error || exc.message);
} finally {
setUploading(false);
}
};
const downloadClap = async () => {
setError('');
try {
await api.post('/api/bulk-annotate/download-clap', {});
startPolling();
} catch (exc) {
setError(exc.response?.data?.error || exc.message);
}
};
const updatePrompt = (idx, value) => {
setResults(prev => prev.map((r, i) => (i === idx ? { ...r, prompt: value } : r)));
};
const toggleSelected = (idx) => {
setSelected(prev => ({ ...prev, [idx]: !prev[idx] }));
};
const toggleAll = () => {
const allSelected = results.every((_, i) => selected[i]);
const next = {};
results.forEach((_, i) => { next[i] = !allSelected; });
setSelected(next);
};
const commit = async () => {
setError('');
setMessage('');
setCommitting(true);
try {
const entries = results
.filter((_, i) => selected[i])
.map(r => ({ file_name: r.file_name, prompt: r.prompt, path: r.path }));
const { data } = await api.post('/api/bulk-annotate/commit', { entries, copy_files: copyFiles });
setMessage(data.message || 'Committed.');
if (onCommitted) onCommitted();
} catch (exc) {
setError(exc.response?.data?.error || exc.message);
} finally {
setCommitting(false);
}
};
const isRunning = status?.state === 'running';
const clapDownload = status?.clap_download;
const clapAvailable = !!status?.clap_available;
const clapDownloading = clapDownload?.state === 'running';
const richBlocked = tier === 'rich' && !clapAvailable;
const progressPct = status?.total ? Math.round((status.current / status.total) * 100) : 0;
return (
<Paper sx={{ p: 2, mt: 3 }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TagsIcon size={20} />
<Typography variant="h6">Bulk Auto-Annotation</Typography>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Point at a folder of audio files and auto-generate prompts.
Basic uses librosa (tempo + key). Rich adds CLAP tagging (genre, mood, instruments).
</Typography>
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon size={18} />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">Annotator Labels</Typography>
{labelsOverridden && (
<Chip size="small" color="primary" variant="outlined" label="Custom" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
These replace the built-in label sets used by Rich (CLAP) auto-annotation.
Type a label and press Enter to add it; click the × on a chip to remove it.
</Typography>
{labelsError && <Alert severity="error" sx={{ mb: 2 }}>{labelsError}</Alert>}
{labelsMessage && <Alert severity="success" sx={{ mb: 2 }}>{labelsMessage}</Alert>}
{['genre', 'mood', 'instruments'].map((category) => (
<Box key={category} sx={{ mb: 2 }}>
<Autocomplete
multiple
freeSolo
options={[]}
value={labels[category] || []}
onChange={(_, value) => updateLabelCategory(category, value)}
disabled={labelsLoading || labelsSaving}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
variant="outlined"
size="small"
label={option}
{...getTagProps({ index })}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label={category.charAt(0).toUpperCase() + category.slice(1)}
placeholder="Add a label…"
size="small"
/>
)}
/>
</Box>
))}
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<Button
variant="contained"
onClick={saveLabels}
startIcon={labelsSaving ? <CircularProgress size={16} /> : <SaveIcon size={16} />}
disabled={labelsLoading || labelsSaving}
>
{labelsSaving ? 'Saving…' : 'Save labels'}
</Button>
<Tooltip title={labelsOverridden ? '' : 'No custom labels to reset'}>
<span>
<Button
variant="outlined"
onClick={resetLabels}
startIcon={<ResetIcon size={16} />}
disabled={labelsSaving || !labelsOverridden}
>
Reset to defaults
</Button>
</span>
</Tooltip>
</Box>
</AccordionDetails>
</Accordion>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}>
<TextField
label="Folder path"
size="small"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="Click Browse to choose a folder…"
sx={{ flexGrow: 1, minWidth: 260 }}
disabled={isRunning}
InputProps={{ readOnly: true }}
/>
{isDocker ? (
<>
<input
ref={folderInputRef}
type="file"
webkitdirectory=""
directory=""
multiple
style={{ display: 'none' }}
onChange={handleFolderSelected}
/>
<Button
variant="outlined"
onClick={openFolderUpload}
startIcon={uploading ? <CircularProgress size={16} /> : <UploadIcon size={16} />}
disabled={isRunning || uploading}
>
{uploading ? 'Uploading…' : 'Upload Folder'}
</Button>
</>
) : (
<Button
variant="outlined"
onClick={pickFolder}
startIcon={<FolderOpenIcon size={16} />}
disabled={isRunning}
>
Browse
</Button>
)}
<FormControl size="small" sx={{ minWidth: 140 }} disabled={isRunning}>
<InputLabel id="tier-label">Tier</InputLabel>
<Select
labelId="tier-label"
value={tier}
label="Tier"
onChange={(e) => setTier(e.target.value)}
>
<MenuItem value="basic">Basic (no download)</MenuItem>
<MenuItem value="rich">Rich (CLAP, ~2.35 GB)</MenuItem>
</Select>
</FormControl>
<Tooltip title={richBlocked ? 'Download CLAP to enable Rich tier' : ''}>
<span>
<Button
variant="contained"
onClick={startAnnotation}
startIcon={isRunning ? <CircularProgress size={16} /> : <TagsIcon size={16} />}
disabled={isRunning || !folderPath || richBlocked}
>
{isRunning ? 'Annotating…' : 'Annotate'}
</Button>
</span>
</Tooltip>
{tier === 'rich' && !clapAvailable && (
<Button
variant="outlined"
onClick={downloadClap}
startIcon={clapDownloading ? <CircularProgress size={16} /> : <CloudDownloadIcon size={16} />}
disabled={clapDownloading}
>
{clapDownloading ? 'Downloading…' : 'Download CLAP'}
</Button>
)}
</Box>
{isRunning && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant={status?.total ? 'determinate' : 'indeterminate'} value={progressPct} />
<Typography variant="caption" color="textSecondary">
{status?.current}/{status?.total} — {status?.current_file}
</Typography>
</Box>
)}
{clapDownload?.state === 'running' && (
<Alert severity="info" sx={{ mb: 2 }}>{clapDownload.message || 'Downloading CLAP checkpoint…'}</Alert>
)}
{clapDownload?.state === 'error' && (
<Alert severity="error" sx={{ mb: 2 }}>CLAP download failed: {clapDownload.error}</Alert>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
{results.length > 0 && (
<>
<TableContainer sx={{ maxHeight: 420, mb: 2 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
checked={results.every((_, i) => selected[i])}
indeterminate={
results.some((_, i) => selected[i]) && !results.every((_, i) => selected[i])
}
onChange={toggleAll}
/>
</TableCell>
<TableCell>File</TableCell>
<TableCell>Prompt (editable)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{results.map((row, idx) => (
<TableRow key={row.file_name + idx} hover>
<TableCell padding="checkbox">
<Checkbox
checked={!!selected[idx]}
onChange={() => toggleSelected(idx)}
disabled={!!row.error}
/>
</TableCell>
<TableCell sx={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Tooltip title={row.path || row.file_name}>
<span>{row.file_name}</span>
</Tooltip>
{row.error && (
<Typography variant="caption" color="error" display="block">
{row.error}
</Typography>
)}
</TableCell>
<TableCell>
<TextField
fullWidth
multiline
size="small"
value={row.prompt || ''}
onChange={(e) => updatePrompt(idx, e.target.value)}
disabled={!!row.error}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl size="small">
<Select
value={copyFiles ? 'copy' : 'link'}
onChange={(e) => setCopyFiles(e.target.value === 'copy')}
>
<MenuItem value="copy">Copy files into data/</MenuItem>
<MenuItem value="link">Symlink files into data/</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={commit}
startIcon={committing ? <CircularProgress size={16} /> : <SaveIcon size={16} />}
disabled={committing || !Object.values(selected).some(Boolean)}
>
{committing ? 'Saving…' : `Save ${Object.values(selected).filter(Boolean).length} to dataset`}
</Button>
</Box>
</>
)}
</Paper>
);
}