Implement job metadata extraction and task management features. Add validation for frame range limits, enhance job and task data structures, and introduce new API endpoints for metadata and task retrieval. Update client-side components to handle metadata extraction and display task statuses. Improve error handling in API responses.
This commit is contained in:
@@ -5,6 +5,7 @@ import VideoPlayer from './VideoPlayer';
|
||||
export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [jobDetails, setJobDetails] = useState(job);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState(null);
|
||||
@@ -42,12 +43,14 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
const loadDetails = async () => {
|
||||
try {
|
||||
const [details, fileList] = await Promise.all([
|
||||
const [details, fileList, taskList] = await Promise.all([
|
||||
jobs.get(job.id),
|
||||
jobs.getFiles(job.id),
|
||||
jobs.getTasks(job.id),
|
||||
]);
|
||||
setJobDetails(details);
|
||||
setFiles(fileList);
|
||||
setTasks(taskList);
|
||||
|
||||
// Check if there's an MP4 output file
|
||||
const mp4File = fileList.find(
|
||||
@@ -151,6 +154,16 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const outputFiles = files.filter((f) => f.file_type === 'output');
|
||||
const inputFiles = files.filter((f) => f.file_type === 'input');
|
||||
|
||||
@@ -269,9 +282,42 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Task Execution
|
||||
Tasks
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{tasks.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Task List</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
className={`flex items-center justify-between p-3 bg-white rounded cursor-pointer hover:bg-gray-100 transition-colors ${
|
||||
selectedTaskId === task.id ? 'ring-2 ring-purple-600' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getTaskStatusColor(task.status)}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
Frame {task.frame_start}
|
||||
{task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}
|
||||
</span>
|
||||
{task.task_type && task.task_type !== 'render' && (
|
||||
<span className="text-xs text-gray-500">({task.task_type})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{task.runner_id && `Runner ${task.runner_id}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskSteps.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Steps</h4>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { jobs } from '../utils/api';
|
||||
|
||||
export default function JobSubmission({ onSuccess }) {
|
||||
@@ -12,6 +12,113 @@ export default function JobSubmission({ onSuccess }) {
|
||||
const [file, setFile] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [metadataStatus, setMetadataStatus] = useState(null); // 'extracting', 'completed', 'error'
|
||||
const [metadata, setMetadata] = useState(null);
|
||||
const [currentJobId, setCurrentJobId] = useState(null);
|
||||
|
||||
// Poll for metadata after file upload
|
||||
useEffect(() => {
|
||||
if (!currentJobId || metadataStatus !== 'extracting') return;
|
||||
|
||||
let pollCount = 0;
|
||||
const maxPolls = 30; // 60 seconds max (30 * 2 seconds)
|
||||
let timeoutId = null;
|
||||
|
||||
const pollMetadata = async () => {
|
||||
pollCount++;
|
||||
|
||||
// Stop polling after timeout
|
||||
if (pollCount > maxPolls) {
|
||||
setMetadataStatus('error');
|
||||
// Cancel temp job on timeout
|
||||
try {
|
||||
await jobs.cancel(currentJobId);
|
||||
} catch (err) {
|
||||
// Ignore errors when canceling
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await jobs.getMetadata(currentJobId);
|
||||
if (metadata) {
|
||||
setMetadata(metadata);
|
||||
setMetadataStatus('completed');
|
||||
// Auto-populate form fields
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
frame_start: metadata.frame_start || prev.frame_start,
|
||||
frame_end: metadata.frame_end || prev.frame_end,
|
||||
output_format: metadata.render_settings?.output_format || prev.output_format,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
// Metadata not ready yet, continue polling (only if 404/not found)
|
||||
if (err.message.includes('404') || err.message.includes('not found')) {
|
||||
// Continue polling via interval
|
||||
} else {
|
||||
setMetadataStatus('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(pollMetadata, 2000);
|
||||
|
||||
// Set timeout to stop polling after 60 seconds
|
||||
timeoutId = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
if (metadataStatus === 'extracting') {
|
||||
setMetadataStatus('error');
|
||||
// Cancel temp job on timeout
|
||||
jobs.cancel(currentJobId).catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
// Cleanup: cancel temp job if component unmounts during extraction
|
||||
if (currentJobId && metadataStatus === 'extracting') {
|
||||
jobs.cancel(currentJobId).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentJobId, metadataStatus]);
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (!selectedFile) {
|
||||
setFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setMetadataStatus(null);
|
||||
setMetadata(null);
|
||||
setCurrentJobId(null);
|
||||
|
||||
// If it's a blend file, create a temporary job to extract metadata
|
||||
if (selectedFile.name.toLowerCase().endsWith('.blend')) {
|
||||
try {
|
||||
// Create a temporary job for metadata extraction
|
||||
const tempJob = await jobs.create({
|
||||
name: 'Metadata Extraction',
|
||||
frame_start: 1,
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
allow_parallel_runners: true,
|
||||
});
|
||||
|
||||
setCurrentJobId(tempJob.id);
|
||||
setMetadataStatus('extracting');
|
||||
|
||||
// Upload file to trigger metadata extraction
|
||||
await jobs.uploadFile(tempJob.id, selectedFile);
|
||||
} catch (err) {
|
||||
console.error('Failed to start metadata extraction:', err);
|
||||
setMetadataStatus('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -27,7 +134,16 @@ export default function JobSubmission({ onSuccess }) {
|
||||
throw new Error('Invalid frame range');
|
||||
}
|
||||
|
||||
// Create job
|
||||
// If we have a temporary job for metadata extraction, cancel it
|
||||
if (currentJobId) {
|
||||
try {
|
||||
await jobs.cancel(currentJobId);
|
||||
} catch (err) {
|
||||
// Ignore errors when canceling temp job
|
||||
}
|
||||
}
|
||||
|
||||
// Create actual job
|
||||
const job = await jobs.create({
|
||||
name: formData.name,
|
||||
frame_start: parseInt(formData.frame_start),
|
||||
@@ -48,6 +164,9 @@ export default function JobSubmission({ onSuccess }) {
|
||||
allow_parallel_runners: true,
|
||||
});
|
||||
setFile(null);
|
||||
setMetadata(null);
|
||||
setMetadataStatus(null);
|
||||
setCurrentJobId(null);
|
||||
e.target.reset();
|
||||
|
||||
if (onSuccess) {
|
||||
@@ -150,10 +269,37 @@ export default function JobSubmission({ onSuccess }) {
|
||||
<input
|
||||
type="file"
|
||||
accept=".blend"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
onChange={handleFileChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100"
|
||||
/>
|
||||
{metadataStatus === 'extracting' && (
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg text-blue-700 text-sm">
|
||||
Extracting metadata from blend file...
|
||||
</div>
|
||||
)}
|
||||
{metadataStatus === 'completed' && metadata && (
|
||||
<div className="mt-2 p-3 bg-green-50 border border-green-200 rounded-lg text-sm">
|
||||
<div className="text-green-700 font-semibold mb-1">Metadata extracted successfully!</div>
|
||||
<div className="text-green-600 text-xs space-y-1">
|
||||
<div>Frames: {metadata.frame_start} - {metadata.frame_end}</div>
|
||||
<div>Resolution: {metadata.render_settings?.resolution_x} x {metadata.render_settings?.resolution_y}</div>
|
||||
<div>Engine: {metadata.render_settings?.engine}</div>
|
||||
<div>Samples: {metadata.render_settings?.samples}</div>
|
||||
<div className="text-gray-600 mt-2">Form fields have been auto-populated. You can adjust them if needed.</div>
|
||||
{(formData.frame_start < metadata.frame_start || formData.frame_end > metadata.frame_end) && (
|
||||
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-yellow-800 text-xs">
|
||||
<strong>Warning:</strong> Your frame range ({formData.frame_start}-{formData.frame_end}) exceeds the blend file's range ({metadata.frame_start}-{metadata.frame_end}). This may cause errors.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metadataStatus === 'error' && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-700 text-sm">
|
||||
Could not extract metadata. Please fill in the form manually.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,9 @@ export const api = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
@@ -16,7 +18,9 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
@@ -26,7 +30,9 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
@@ -39,7 +45,9 @@ export const api = {
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
@@ -112,6 +120,14 @@ export const jobs = {
|
||||
async retryTask(jobId, taskId) {
|
||||
return api.post(`/jobs/${jobId}/tasks/${taskId}/retry`);
|
||||
},
|
||||
|
||||
async getMetadata(jobId) {
|
||||
return api.get(`/jobs/${jobId}/metadata`);
|
||||
},
|
||||
|
||||
async getTasks(jobId) {
|
||||
return api.get(`/jobs/${jobId}/tasks`);
|
||||
},
|
||||
};
|
||||
|
||||
export const runners = {
|
||||
|
||||
Reference in New Issue
Block a user