Implement context archive handling and metadata extraction for render jobs. Add functionality to check for Blender availability, create context archives, and extract metadata from .blend files. Update job creation and retrieval processes to support new metadata structure and context file management. Enhance client-side components to display context files and integrate new API endpoints for context handling.
This commit is contained in:
154
web/src/components/FileExplorer.jsx
Normal file
154
web/src/components/FileExplorer.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function FileExplorer({ files, onDownload, onPreview, isImageFile }) {
|
||||
const [expandedPaths, setExpandedPaths] = useState(new Set());
|
||||
|
||||
// Build directory tree from file paths
|
||||
const buildTree = (files) => {
|
||||
const tree = {};
|
||||
|
||||
files.forEach(file => {
|
||||
const path = file.file_name;
|
||||
// Handle both paths with slashes and single filenames
|
||||
const parts = path.includes('/') ? path.split('/').filter(p => p) : [path];
|
||||
|
||||
// If it's a single file at root (no slashes), treat it specially
|
||||
if (parts.length === 1 && !path.includes('/')) {
|
||||
tree[parts[0]] = {
|
||||
name: parts[0],
|
||||
isFile: true,
|
||||
file: file,
|
||||
children: {},
|
||||
path: parts[0]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let current = tree;
|
||||
parts.forEach((part, index) => {
|
||||
if (!current[part]) {
|
||||
current[part] = {
|
||||
name: part,
|
||||
isFile: index === parts.length - 1,
|
||||
file: index === parts.length - 1 ? file : null,
|
||||
children: {},
|
||||
path: parts.slice(0, index + 1).join('/')
|
||||
};
|
||||
}
|
||||
current = current[part].children;
|
||||
});
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
const togglePath = (path) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const renderTree = (node, level = 0, parentPath = '') => {
|
||||
const items = Object.values(node).sort((a, b) => {
|
||||
// Directories first, then files
|
||||
if (a.isFile !== b.isFile) {
|
||||
return a.isFile ? 1 : -1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const fullPath = parentPath ? `${parentPath}/${item.name}` : item.name;
|
||||
const isExpanded = expandedPaths.has(fullPath);
|
||||
const indent = level * 20;
|
||||
|
||||
if (item.isFile) {
|
||||
const file = item.file;
|
||||
const isImage = isImageFile && isImageFile(file.file_name);
|
||||
const sizeMB = (file.file_size / 1024 / 1024).toFixed(2);
|
||||
const isArchive = file.file_name.endsWith('.tar.gz') || file.file_name.endsWith('.zip');
|
||||
|
||||
return (
|
||||
<div key={fullPath} className="flex items-center justify-between py-1.5 hover:bg-gray-800/50 rounded px-2" style={{ paddingLeft: `${indent + 8}px` }}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-gray-500 text-sm">{isArchive ? '📦' : '📄'}</span>
|
||||
<span className="text-gray-200 text-sm truncate" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs ml-2">{sizeMB} MB</span>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4 shrink-0">
|
||||
{isImage && onPreview && (
|
||||
<button
|
||||
onClick={() => onPreview(file)}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-500 transition-colors"
|
||||
title="Preview"
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
)}
|
||||
{onDownload && file.id && (
|
||||
<button
|
||||
onClick={() => onDownload(file.id, file.file_name)}
|
||||
className="px-2 py-1 bg-orange-600 text-white rounded text-xs hover:bg-orange-500 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const hasChildren = Object.keys(item.children).length > 0;
|
||||
return (
|
||||
<div key={fullPath}>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1 hover:bg-gray-800/50 rounded px-2 cursor-pointer"
|
||||
style={{ paddingLeft: `${indent + 8}px` }}
|
||||
onClick={() => hasChildren && togglePath(fullPath)}
|
||||
>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{hasChildren ? (isExpanded ? '📂' : '📁') : '📁'}
|
||||
</span>
|
||||
<span className="text-gray-300 text-sm font-medium">{item.name}</span>
|
||||
{hasChildren && (
|
||||
<span className="text-gray-500 text-xs ml-2">
|
||||
({Object.keys(item.children).length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{renderTree(item.children, level + 1, fullPath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tree = buildTree(files);
|
||||
|
||||
if (Object.keys(tree).length === 0) {
|
||||
return (
|
||||
<div className="text-gray-400 text-sm py-4 text-center">
|
||||
No files
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
|
||||
<div className="space-y-1">
|
||||
{renderTree(tree)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { jobs } from '../utils/api';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import FileExplorer from './FileExplorer';
|
||||
|
||||
export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [jobDetails, setJobDetails] = useState(job);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [contextFiles, setContextFiles] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
@@ -89,6 +91,15 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
setJobDetails(details);
|
||||
setFiles(fileList);
|
||||
setTasks(taskList);
|
||||
|
||||
// Fetch context archive contents separately (may not exist for old jobs)
|
||||
try {
|
||||
const contextList = await jobs.getContextArchive(job.id);
|
||||
setContextFiles(contextList || []);
|
||||
} catch (error) {
|
||||
// Context archive may not exist for old jobs
|
||||
setContextFiles([]);
|
||||
}
|
||||
|
||||
// Only load task data (logs/steps) for tasks that don't have data yet
|
||||
// This prevents overwriting logs that are being streamed via WebSocket
|
||||
@@ -446,7 +457,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoUrl && jobDetails.output_format === 'MP4' && (
|
||||
{videoUrl && (jobDetails.output_format === 'EXR_264_MP4' || jobDetails.output_format === 'EXR_AV1_MP4') && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Video Preview
|
||||
@@ -455,68 +466,38 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Context Archive
|
||||
</h3>
|
||||
<FileExplorer
|
||||
files={contextFiles.map(f => ({
|
||||
id: 0, // Context files don't have IDs
|
||||
file_name: f.path || f.name || '',
|
||||
file_size: f.size || 0,
|
||||
file_type: 'input'
|
||||
}))}
|
||||
onDownload={null} // Context files can't be downloaded individually
|
||||
isImageFile={isImageFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Output Files
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{outputFiles.map((file) => {
|
||||
const isImage = isImageFile(file.file_name);
|
||||
const imageUrl = isImage ? jobs.downloadFile(job.id, file.id) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-100">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isImage && imageUrl && (
|
||||
<button
|
||||
onClick={() => setPreviewImage({ url: imageUrl, fileName: file.file_name })}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDownload(file.id, file.file_name)}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Input Files
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{inputFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="p-3 bg-gray-900 rounded-lg border border-gray-700"
|
||||
>
|
||||
<p className="font-medium text-gray-100">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FileExplorer
|
||||
files={outputFiles}
|
||||
onDownload={handleDownload}
|
||||
onPreview={(file) => {
|
||||
const imageUrl = jobs.downloadFile(job.id, file.id);
|
||||
setPreviewImage({ url: imageUrl, fileName: file.file_name });
|
||||
}}
|
||||
isImageFile={isImageFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,22 @@ export default function JobList() {
|
||||
}
|
||||
};
|
||||
|
||||
// Keep selectedJob in sync with the job list when it refreshes
|
||||
// This prevents the selected job from becoming stale when format selection or other actions trigger updates
|
||||
useEffect(() => {
|
||||
if (selectedJob && jobList.length > 0) {
|
||||
const freshJob = jobList.find(j => j.id === selectedJob.id);
|
||||
if (freshJob) {
|
||||
// Update to the fresh object from the list to keep it in sync
|
||||
setSelectedJob(freshJob);
|
||||
} else {
|
||||
// Job was deleted or no longer exists, clear selection
|
||||
setSelectedJob(null);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobList]); // Only depend on jobList, not selectedJob to avoid infinite loops
|
||||
|
||||
const handleCancel = async (jobId) => {
|
||||
if (!confirm('Are you sure you want to cancel this job?')) return;
|
||||
try {
|
||||
|
||||
@@ -10,13 +10,16 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
allow_parallel_runners: true,
|
||||
render_settings: null, // Will contain engine settings
|
||||
});
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
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);
|
||||
const [uploadSessionId, setUploadSessionId] = useState(null); // Session ID from file upload
|
||||
const [createdJob, setCreatedJob] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -28,121 +31,15 @@ export default function JobSubmission({ onSuccess }) {
|
||||
const isCompletedRef = useRef(false);
|
||||
const currentJobIdRef = useRef(null);
|
||||
const cleanupRef = useRef(null);
|
||||
|
||||
// Poll for metadata after file upload
|
||||
const formatManuallyChangedRef = useRef(false); // Track if user manually changed output format
|
||||
const stepRef = useRef(step); // Track current step to avoid stale closures
|
||||
|
||||
// Keep stepRef in sync with step state
|
||||
useEffect(() => {
|
||||
if (!currentJobId || metadataStatus !== 'extracting') {
|
||||
// Reset refs when not extracting
|
||||
isCancelledRef.current = false;
|
||||
isCompletedRef.current = false;
|
||||
currentJobIdRef.current = null;
|
||||
// Clear any pending cleanup
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
stepRef.current = step;
|
||||
}, [step]);
|
||||
|
||||
// Reset refs for new job
|
||||
if (currentJobIdRef.current !== currentJobId) {
|
||||
isCancelledRef.current = false;
|
||||
isCompletedRef.current = false;
|
||||
currentJobIdRef.current = currentJobId;
|
||||
}
|
||||
|
||||
let pollCount = 0;
|
||||
const maxPolls = 30; // 60 seconds max (30 * 2 seconds)
|
||||
let timeoutId = null;
|
||||
let interval = null;
|
||||
let mounted = true; // Track if effect is still mounted
|
||||
|
||||
const pollMetadata = async () => {
|
||||
if (!mounted || isCancelledRef.current || isCompletedRef.current) return;
|
||||
pollCount++;
|
||||
|
||||
// Stop polling after timeout
|
||||
if (pollCount > maxPolls) {
|
||||
if (!mounted) return;
|
||||
setMetadataStatus('error');
|
||||
// Cancel temp job on timeout
|
||||
try {
|
||||
await jobs.cancel(currentJobId);
|
||||
isCancelledRef.current = true;
|
||||
} catch (err) {
|
||||
// Ignore errors when canceling
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await jobs.getMetadata(currentJobId);
|
||||
if (metadata && mounted) {
|
||||
setMetadata(metadata);
|
||||
setMetadataStatus('completed');
|
||||
isCompletedRef.current = true; // Mark as 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,
|
||||
}));
|
||||
// Stop polling on success
|
||||
if (interval) clearInterval(interval);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
// 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');
|
||||
// Stop polling on error
|
||||
if (interval) clearInterval(interval);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(pollMetadata, 2000);
|
||||
|
||||
// Set timeout to stop polling after 60 seconds
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
if (interval) clearInterval(interval);
|
||||
if (!isCancelledRef.current && !isCompletedRef.current) {
|
||||
setMetadataStatus('error');
|
||||
// Cancel temp job on timeout
|
||||
jobs.cancel(currentJobId).catch(() => {});
|
||||
isCancelledRef.current = true;
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Store cleanup function in ref so we can check if it should run
|
||||
cleanupRef.current = () => {
|
||||
mounted = false;
|
||||
if (interval) clearInterval(interval);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
// DO NOT cancel the job in cleanup - let it run to completion
|
||||
// The job will be cleaned up when the user submits the actual job or navigates away
|
||||
};
|
||||
|
||||
return cleanupRef.current;
|
||||
}, [currentJobId, metadataStatus]); // Include metadataStatus to properly track state changes
|
||||
|
||||
// Separate effect to handle component unmount - only cancel if truly unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Only cancel on actual component unmount, not on effect re-run
|
||||
// Check if we're still extracting and haven't completed
|
||||
if (currentJobIdRef.current && !isCompletedRef.current && !isCancelledRef.current && metadataStatus === 'extracting') {
|
||||
// Only cancel if we're actually unmounting (not just re-rendering)
|
||||
// This is a last resort - ideally we should let metadata extraction complete
|
||||
jobs.cancel(currentJobIdRef.current).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []); // Empty deps - only runs on mount/unmount
|
||||
// No polling needed - metadata is extracted synchronously during upload
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
@@ -155,36 +52,35 @@ export default function JobSubmission({ onSuccess }) {
|
||||
setMetadataStatus(null);
|
||||
setMetadata(null);
|
||||
setCurrentJobId(null);
|
||||
setUploadSessionId(null);
|
||||
setUploadProgress(0);
|
||||
setBlendFiles([]);
|
||||
setSelectedMainBlend('');
|
||||
formatManuallyChangedRef.current = false; // Reset when new file is selected
|
||||
|
||||
const isBlend = selectedFile.name.toLowerCase().endsWith('.blend');
|
||||
const isZip = selectedFile.name.toLowerCase().endsWith('.zip');
|
||||
|
||||
// If it's a blend file or ZIP, create a temporary job to extract metadata
|
||||
// If it's a blend file or ZIP, upload and extract metadata
|
||||
if (isBlend || isZip) {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// Create a temporary job for metadata extraction
|
||||
const tempJob = await jobs.create({
|
||||
job_type: 'metadata',
|
||||
name: 'Metadata Extraction',
|
||||
});
|
||||
|
||||
setCurrentJobId(tempJob.id);
|
||||
setMetadataStatus('extracting');
|
||||
|
||||
// Upload file to trigger metadata extraction with progress tracking
|
||||
const result = await jobs.uploadFile(tempJob.id, selectedFile, (progress) => {
|
||||
// Upload file to new endpoint (no job required)
|
||||
const result = await jobs.uploadFileForJobCreation(selectedFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
}, selectedMainBlend || undefined);
|
||||
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
|
||||
// Store session ID for later use when creating the job
|
||||
if (result.session_id) {
|
||||
setUploadSessionId(result.session_id);
|
||||
}
|
||||
|
||||
// Check if ZIP extraction found multiple blend files
|
||||
if (result.zip_extracted && result.blend_files && result.blend_files.length > 1) {
|
||||
setBlendFiles(result.blend_files);
|
||||
@@ -192,21 +88,55 @@ export default function JobSubmission({ onSuccess }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If metadata was extracted, use it
|
||||
if (result.metadata_extracted && result.metadata) {
|
||||
setMetadata(result.metadata);
|
||||
setMetadataStatus('completed');
|
||||
isCompletedRef.current = true;
|
||||
|
||||
// Auto-populate form fields
|
||||
let normalizedFormat = result.metadata.render_settings?.output_format;
|
||||
if (normalizedFormat) {
|
||||
const formatMap = {
|
||||
'OPEN_EXR': 'EXR',
|
||||
'EXR': 'EXR',
|
||||
'PNG': 'PNG',
|
||||
'JPEG': 'JPEG',
|
||||
'JPG': 'JPEG',
|
||||
};
|
||||
normalizedFormat = formatMap[normalizedFormat.toUpperCase()] || normalizedFormat;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
frame_start: result.metadata.frame_start || prev.frame_start,
|
||||
frame_end: result.metadata.frame_end || prev.frame_end,
|
||||
output_format: normalizedFormat || prev.output_format,
|
||||
render_settings: result.metadata.render_settings ? {
|
||||
...result.metadata.render_settings,
|
||||
engine_settings: result.metadata.render_settings.engine_settings || {},
|
||||
} : null,
|
||||
}));
|
||||
} else {
|
||||
setMetadataStatus('error');
|
||||
}
|
||||
|
||||
// If main blend file was auto-detected or specified, continue
|
||||
if (result.main_blend_file) {
|
||||
setSelectedMainBlend(result.main_blend_file);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start metadata extraction:', err);
|
||||
console.error('Failed to upload file and extract metadata:', err);
|
||||
setMetadataStatus('error');
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setError(err.message || 'Failed to upload file and extract metadata');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlendFileSelect = async () => {
|
||||
if (!selectedMainBlend || !currentJobId) {
|
||||
if (!selectedMainBlend || !file) {
|
||||
setError('Please select a main blend file');
|
||||
return;
|
||||
}
|
||||
@@ -214,20 +144,59 @@ export default function JobSubmission({ onSuccess }) {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setMetadataStatus('extracting');
|
||||
|
||||
// Re-upload with selected main blend file
|
||||
const result = await jobs.uploadFile(currentJobId, file, (progress) => {
|
||||
const result = await jobs.uploadFileForJobCreation(file, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
}, selectedMainBlend);
|
||||
|
||||
setUploadProgress(100);
|
||||
setIsUploading(false);
|
||||
setBlendFiles([]);
|
||||
setMetadataStatus('extracting');
|
||||
|
||||
// Store session ID
|
||||
if (result.session_id) {
|
||||
setUploadSessionId(result.session_id);
|
||||
}
|
||||
|
||||
// If metadata was extracted, use it
|
||||
if (result.metadata_extracted && result.metadata) {
|
||||
setMetadata(result.metadata);
|
||||
setMetadataStatus('completed');
|
||||
isCompletedRef.current = true;
|
||||
|
||||
// Auto-populate form fields
|
||||
let normalizedFormat = result.metadata.render_settings?.output_format;
|
||||
if (normalizedFormat) {
|
||||
const formatMap = {
|
||||
'OPEN_EXR': 'EXR',
|
||||
'EXR': 'EXR',
|
||||
'PNG': 'PNG',
|
||||
'JPEG': 'JPEG',
|
||||
'JPG': 'JPEG',
|
||||
};
|
||||
normalizedFormat = formatMap[normalizedFormat.toUpperCase()] || normalizedFormat;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
frame_start: result.metadata.frame_start || prev.frame_start,
|
||||
frame_end: result.metadata.frame_end || prev.frame_end,
|
||||
output_format: normalizedFormat || prev.output_format,
|
||||
render_settings: result.metadata.render_settings ? {
|
||||
...result.metadata.render_settings,
|
||||
engine_settings: result.metadata.render_settings.engine_settings || {},
|
||||
} : null,
|
||||
}));
|
||||
} else {
|
||||
setMetadataStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to upload with selected blend file:', err);
|
||||
setError(err.message || 'Failed to upload with selected blend file');
|
||||
setIsUploading(false);
|
||||
setMetadataStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,20 +220,23 @@ export default function JobSubmission({ onSuccess }) {
|
||||
throw new Error('Please select a Blender file');
|
||||
}
|
||||
|
||||
if (!uploadSessionId) {
|
||||
throw new Error('File upload session not found. Please upload the file again.');
|
||||
}
|
||||
|
||||
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 render job with upload session ID if we have one
|
||||
const renderSettings = formData.render_settings && formData.render_settings.engine_settings ? {
|
||||
engine: formData.render_settings.engine || 'cycles',
|
||||
resolution_x: formData.render_settings.resolution_x || 1920,
|
||||
resolution_y: formData.render_settings.resolution_y || 1080,
|
||||
engine_settings: formData.render_settings.engine_settings,
|
||||
} : null;
|
||||
|
||||
// Create actual render job, linking it to the metadata job if we have one
|
||||
console.log('Submitting job with output_format:', formData.output_format, 'formatManuallyChanged:', formatManuallyChangedRef.current);
|
||||
const job = await jobs.create({
|
||||
job_type: 'render',
|
||||
name: formData.name,
|
||||
@@ -272,12 +244,10 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_end: parseInt(formData.frame_end),
|
||||
output_format: formData.output_format,
|
||||
allow_parallel_runners: formData.allow_parallel_runners,
|
||||
metadata_job_id: currentJobId || undefined, // Link to metadata job to copy input files
|
||||
render_settings: renderSettings,
|
||||
upload_session_id: uploadSessionId || undefined, // Pass session ID to move context archive
|
||||
});
|
||||
|
||||
// Note: File is already uploaded to metadata job, so we don't need to upload again
|
||||
// The backend will copy the file reference from the metadata job
|
||||
|
||||
// Fetch the full job details
|
||||
const jobDetails = await jobs.get(job.id);
|
||||
|
||||
@@ -298,11 +268,14 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
allow_parallel_runners: true,
|
||||
render_settings: null,
|
||||
});
|
||||
setShowAdvancedSettings(false);
|
||||
setFile(null);
|
||||
setMetadata(null);
|
||||
setMetadataStatus(null);
|
||||
setCurrentJobId(null);
|
||||
formatManuallyChangedRef.current = false;
|
||||
setStep(1);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
@@ -427,7 +400,12 @@ export default function JobSubmission({ onSuccess }) {
|
||||
<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>
|
||||
{metadata.render_settings?.engine_settings?.samples && (
|
||||
<div>Cycles Samples: {metadata.render_settings.engine_settings.samples}</div>
|
||||
)}
|
||||
{metadata.render_settings?.engine_settings?.taa_render_samples && (
|
||||
<div>EEVEE Samples: {metadata.render_settings.engine_settings.taa_render_samples}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -511,13 +489,17 @@ export default function JobSubmission({ onSuccess }) {
|
||||
</label>
|
||||
<select
|
||||
value={formData.output_format}
|
||||
onChange={(e) => setFormData({ ...formData, output_format: e.target.value })}
|
||||
onChange={(e) => {
|
||||
formatManuallyChangedRef.current = true;
|
||||
setFormData({ ...formData, output_format: e.target.value });
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="PNG">PNG</option>
|
||||
<option value="JPEG">JPEG</option>
|
||||
<option value="EXR">EXR</option>
|
||||
<option value="MP4">MP4</option>
|
||||
<option value="EXR_264_MP4">EXR_264_MP4 (High Quality Video Without Alpha)</option>
|
||||
<option value="EXR_AV1_MP4">EXR_AV1_MP4 (High Quality Video With Alpha)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -541,11 +523,347 @@ export default function JobSubmission({ onSuccess }) {
|
||||
<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>
|
||||
{metadata.render_settings?.engine_settings?.samples && (
|
||||
<div>Samples: {metadata.render_settings.engine_settings.samples}</div>
|
||||
)}
|
||||
{metadata.render_settings?.engine_settings?.taa_render_samples && (
|
||||
<div>EEVEE Samples: {metadata.render_settings.engine_settings.taa_render_samples}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Render Settings */}
|
||||
{formData.render_settings && (
|
||||
<div className="border border-gray-700 rounded-lg p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
className="w-full flex items-center justify-between text-left text-sm font-medium text-gray-300 hover:text-gray-100"
|
||||
>
|
||||
<span>Advanced Render Settings</span>
|
||||
<span className="text-gray-500">{showAdvancedSettings ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
|
||||
{showAdvancedSettings && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Engine Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Render Engine
|
||||
</label>
|
||||
<select
|
||||
value={formData.render_settings.engine || 'cycles'}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine: e.target.value,
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
>
|
||||
<option value="cycles">Cycles</option>
|
||||
<option value="eevee">EEVEE</option>
|
||||
<option value="eevee_next">EEVEE Next</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Resolution X
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.resolution_x || 1920}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
resolution_x: parseInt(e.target.value) || 1920,
|
||||
}
|
||||
})}
|
||||
min="1"
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Resolution Y
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.resolution_y || 1080}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
resolution_y: parseInt(e.target.value) || 1080,
|
||||
}
|
||||
})}
|
||||
min="1"
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cycles Settings */}
|
||||
{formData.render_settings.engine === 'cycles' && formData.render_settings.engine_settings && (
|
||||
<div className="space-y-3 p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-300 mb-2">Cycles Settings</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">
|
||||
Samples
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.engine_settings.samples || 128}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
samples: parseInt(e.target.value) || 128,
|
||||
}
|
||||
}
|
||||
})}
|
||||
min="1"
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-600 rounded text-gray-100 text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_denoising || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_denoising: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Use Denoising
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_adaptive_sampling || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_adaptive_sampling: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Adaptive Sampling
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">
|
||||
Max Bounces
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.engine_settings.max_bounces || 12}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
max_bounces: parseInt(e.target.value) || 12,
|
||||
}
|
||||
}
|
||||
})}
|
||||
min="0"
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-600 rounded text-gray-100 text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">
|
||||
Diffuse Bounces
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.engine_settings.diffuse_bounces || 4}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
diffuse_bounces: parseInt(e.target.value) || 4,
|
||||
}
|
||||
}
|
||||
})}
|
||||
min="0"
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-600 rounded text-gray-100 text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">
|
||||
Glossy Bounces
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.engine_settings.glossy_bounces || 4}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
glossy_bounces: parseInt(e.target.value) || 4,
|
||||
}
|
||||
}
|
||||
})}
|
||||
min="0"
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-600 rounded text-gray-100 text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EEVEE Settings */}
|
||||
{(formData.render_settings.engine === 'eevee' || formData.render_settings.engine === 'eevee_next') && formData.render_settings.engine_settings && (
|
||||
<div className="space-y-3 p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-300 mb-2">EEVEE Settings</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">
|
||||
Render Samples
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.render_settings.engine_settings.taa_render_samples || 64}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
taa_render_samples: parseInt(e.target.value) || 64,
|
||||
}
|
||||
}
|
||||
})}
|
||||
min="1"
|
||||
className="w-full px-3 py-1.5 bg-gray-800 border border-gray-600 rounded text-gray-100 text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_bloom || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_bloom: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Bloom
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_ssr || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_ssr: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Screen Space Reflections (SSR)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_ssao || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_ssao: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Screen Space Ambient Occlusion (SSAO)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.render_settings.engine_settings.use_volumetric || false}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
render_settings: {
|
||||
...formData.render_settings,
|
||||
engine_settings: {
|
||||
...formData.render_settings.engine_settings,
|
||||
use_volumetric: e.target.checked,
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-800 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-xs text-gray-400">
|
||||
Volumetric Rendering
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
Selected file: {file?.name}
|
||||
|
||||
Reference in New Issue
Block a user