317 lines
11 KiB
JavaScript
317 lines
11 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { jobs } from '../utils/api';
|
|
|
|
export default function JobSubmission({ onSuccess }) {
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
frame_start: 1,
|
|
frame_end: 10,
|
|
output_format: 'PNG',
|
|
allow_parallel_runners: true,
|
|
});
|
|
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();
|
|
setError('');
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
if (!file) {
|
|
throw new Error('Please select a Blender file');
|
|
}
|
|
|
|
if (formData.frame_start < 0 || formData.frame_end < formData.frame_start) {
|
|
throw new Error('Invalid frame range');
|
|
}
|
|
|
|
// 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),
|
|
frame_end: parseInt(formData.frame_end),
|
|
output_format: formData.output_format,
|
|
allow_parallel_runners: formData.allow_parallel_runners,
|
|
});
|
|
|
|
// Upload file
|
|
await jobs.uploadFile(job.id, file);
|
|
|
|
// Reset form
|
|
setFormData({
|
|
name: '',
|
|
frame_start: 1,
|
|
frame_end: 10,
|
|
output_format: 'PNG',
|
|
allow_parallel_runners: true,
|
|
});
|
|
setFile(null);
|
|
setMetadata(null);
|
|
setMetadataStatus(null);
|
|
setCurrentJobId(null);
|
|
e.target.reset();
|
|
|
|
if (onSuccess) {
|
|
onSuccess();
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || 'Failed to submit job');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-md p-8 max-w-2xl mx-auto">
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Submit New Job</h2>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Job Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
required
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
placeholder="My Render Job"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Frame Start
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.frame_start}
|
|
onChange={(e) => setFormData({ ...formData, frame_start: e.target.value })}
|
|
required
|
|
min="0"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Frame End
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.frame_end}
|
|
onChange={(e) => setFormData({ ...formData, frame_end: e.target.value })}
|
|
required
|
|
min={formData.frame_start}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Output Format
|
|
</label>
|
|
<select
|
|
value={formData.output_format}
|
|
onChange={(e) => setFormData({ ...formData, output_format: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
>
|
|
<option value="PNG">PNG</option>
|
|
<option value="JPEG">JPEG</option>
|
|
<option value="EXR">EXR</option>
|
|
<option value="MP4">MP4</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
id="allow_parallel_runners"
|
|
checked={formData.allow_parallel_runners}
|
|
onChange={(e) => setFormData({ ...formData, allow_parallel_runners: e.target.checked })}
|
|
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
|
|
/>
|
|
<label htmlFor="allow_parallel_runners" className="ml-2 block text-sm text-gray-700">
|
|
Allow multiple runners to work on this job simultaneously
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Blender File (.blend)
|
|
</label>
|
|
<input
|
|
type="file"
|
|
accept=".blend"
|
|
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
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="w-full px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Job'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|