Update .gitignore to include log files and database journal files. Modify go.mod to update dependencies for go-sqlite3 and cloud.google.com/go/compute/metadata. Enhance Makefile to include logging options for manager and runner commands. Introduce new job token handling in auth package and implement database migration scripts. Refactor manager and runner components to improve job processing and metadata extraction. Add support for video preview in frontend components and enhance WebSocket management for channel subscriptions.
This commit is contained in:
@@ -33,14 +33,16 @@ export default function AdminPanel() {
|
||||
}
|
||||
},
|
||||
message: (data) => {
|
||||
// Handle subscription responses
|
||||
// Handle subscription responses - update both local refs and wsManager
|
||||
if (data.type === 'subscribed' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.add(data.channel);
|
||||
wsManager.confirmSubscription(data.channel);
|
||||
console.log('Successfully subscribed to channel:', data.channel);
|
||||
} else if (data.type === 'subscription_error' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.delete(data.channel);
|
||||
wsManager.failSubscription(data.channel);
|
||||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||||
}
|
||||
|
||||
@@ -83,27 +85,22 @@ export default function AdminPanel() {
|
||||
|
||||
const subscribeToRunners = () => {
|
||||
const channel = 'runners';
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
// Don't subscribe if already subscribed or pending
|
||||
if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) {
|
||||
return;
|
||||
}
|
||||
wsManager.send({ type: 'subscribe', channel });
|
||||
wsManager.subscribeToChannel(channel);
|
||||
subscribedChannelsRef.current.add(channel);
|
||||
pendingSubscriptionsRef.current.add(channel);
|
||||
console.log('Subscribing to runners channel');
|
||||
};
|
||||
|
||||
const unsubscribeFromRunners = () => {
|
||||
const channel = 'runners';
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
return; // Not subscribed
|
||||
}
|
||||
wsManager.send({ type: 'unsubscribe', channel });
|
||||
wsManager.unsubscribeFromChannel(channel);
|
||||
subscribedChannelsRef.current.delete(channel);
|
||||
pendingSubscriptionsRef.current.delete(channel);
|
||||
console.log('Unsubscribed from runners channel');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function FileExplorer({ files, onDownload, onPreview, isImageFile }) {
|
||||
export default function FileExplorer({ files, onDownload, onPreview, onVideoPreview, isImageFile }) {
|
||||
const [expandedPaths, setExpandedPaths] = useState(new Set()); // Root folder collapsed by default
|
||||
|
||||
// Build directory tree from file paths
|
||||
@@ -69,19 +69,29 @@ export default function FileExplorer({ files, onDownload, onPreview, isImageFile
|
||||
if (item.isFile) {
|
||||
const file = item.file;
|
||||
const isImage = isImageFile && isImageFile(file.file_name);
|
||||
const isVideo = file.file_name.toLowerCase().endsWith('.mp4');
|
||||
const sizeMB = (file.file_size / 1024 / 1024).toFixed(2);
|
||||
const isArchive = file.file_name.endsWith('.tar') || 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-500 text-sm">{isArchive ? '📦' : isVideo ? '🎬' : '📄'}</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">
|
||||
{isVideo && onVideoPreview && (
|
||||
<button
|
||||
onClick={() => onVideoPreview(file)}
|
||||
className="px-2 py-1 bg-purple-600 text-white rounded text-xs hover:bg-purple-500 transition-colors"
|
||||
title="Play Video"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
{isImage && onPreview && (
|
||||
<button
|
||||
onClick={() => onPreview(file)}
|
||||
|
||||
@@ -12,27 +12,34 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [contextFiles, setContextFiles] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
// Store steps and logs per task: { taskId: { steps: [], logs: [] } }
|
||||
const [taskData, setTaskData] = useState({});
|
||||
// Track which tasks and steps are expanded
|
||||
// Track which tasks are expanded
|
||||
const [expandedTasks, setExpandedTasks] = useState(new Set());
|
||||
const [expandedSteps, setExpandedSteps] = useState(new Set());
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState(null); // { url, fileName } or null
|
||||
const [previewVideo, setPreviewVideo] = useState(null); // { url, fileName } or null
|
||||
const listenerIdRef = useRef(null); // Listener ID for shared WebSocket
|
||||
const subscribedChannelsRef = useRef(new Set()); // Track confirmed subscribed channels
|
||||
const pendingSubscriptionsRef = useRef(new Set()); // Track pending subscriptions (waiting for confirmation)
|
||||
const logContainerRefs = useRef({}); // Refs for each step's log container
|
||||
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per step
|
||||
const logContainerRefs = useRef({}); // Refs for each task's log container
|
||||
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per task
|
||||
const abortControllerRef = useRef(null); // AbortController for HTTP requests
|
||||
|
||||
// Sync job prop to state when it changes
|
||||
useEffect(() => {
|
||||
setJobDetails(job);
|
||||
}, [job.id, job.status, job.progress]);
|
||||
if (job) {
|
||||
setJobDetails(job);
|
||||
}
|
||||
}, [job?.id, job?.status, job?.progress]);
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
console.warn('JobDetails: job or job.id is undefined, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new AbortController for this effect
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
@@ -73,10 +80,10 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
listenerIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [job.id]);
|
||||
}, [job?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update log subscriptions based on expanded tasks (not steps)
|
||||
// Update log subscriptions based on expanded tasks
|
||||
updateLogSubscriptions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expandedTasks, tasks.length, jobDetails.status]); // Use tasks.length instead of tasks to avoid unnecessary re-runs
|
||||
@@ -105,6 +112,12 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}, [taskData]);
|
||||
|
||||
const loadDetails = async () => {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
console.warn('JobDetails: Cannot load details - job or job.id is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Use summary endpoint for tasks initially - much faster
|
||||
@@ -112,7 +125,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [details, fileList, taskListResult] = await Promise.all([
|
||||
jobs.get(job.id, { signal }),
|
||||
jobs.getFiles(job.id, { limit: 50, signal }), // Only load first page of files
|
||||
jobs.getTasksSummary(job.id, { sort: 'frame_start:asc', signal }), // Get all tasks
|
||||
jobs.getTasksSummary(job.id, { sort: 'frame:asc', signal }), // Get all tasks
|
||||
]);
|
||||
|
||||
// Check if request was aborted
|
||||
@@ -139,8 +152,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -180,14 +192,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's an MP4 output file
|
||||
const fileArray = Array.isArray(fileData) ? fileData : [];
|
||||
const mp4File = fileArray.find(
|
||||
(f) => f.file_type === 'output' && f.file_name && f.file_name.endsWith('.mp4')
|
||||
);
|
||||
if (mp4File) {
|
||||
setVideoUrl(jobs.getVideoUrl(job.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load job details:', error);
|
||||
} finally {
|
||||
@@ -278,27 +282,17 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
const subscribe = (channel) => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
// Don't subscribe if already subscribed or pending
|
||||
if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) {
|
||||
return; // Already subscribed or subscription pending
|
||||
}
|
||||
wsManager.send({ type: 'subscribe', channel });
|
||||
pendingSubscriptionsRef.current.add(channel); // Mark as pending
|
||||
// Use wsManager's channel subscription (handles reconnect automatically)
|
||||
wsManager.subscribeToChannel(channel);
|
||||
subscribedChannelsRef.current.add(channel);
|
||||
pendingSubscriptionsRef.current.add(channel);
|
||||
};
|
||||
|
||||
const unsubscribe = (channel) => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
return; // Not subscribed
|
||||
}
|
||||
wsManager.send({ type: 'unsubscribe', channel });
|
||||
// Use wsManager's channel unsubscription
|
||||
wsManager.unsubscribeFromChannel(channel);
|
||||
subscribedChannelsRef.current.delete(channel);
|
||||
console.log('Unsubscribed from channel:', channel);
|
||||
pendingSubscriptionsRef.current.delete(channel);
|
||||
};
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
@@ -308,7 +302,8 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
const updateLogSubscriptions = () => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,7 +321,9 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
// Subscribe to new channels
|
||||
shouldSubscribe.forEach(channel => {
|
||||
subscribe(channel);
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
subscribe(channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from channels that shouldn't be subscribed
|
||||
@@ -341,23 +338,28 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
try {
|
||||
console.log('JobDetails: Client WebSocket message received:', data.type, data.channel, data);
|
||||
|
||||
// Handle subscription responses
|
||||
// Handle subscription responses - update both local refs and wsManager
|
||||
if (data.type === 'subscribed' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel); // Remove from pending
|
||||
subscribedChannelsRef.current.add(data.channel); // Add to confirmed
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.add(data.channel);
|
||||
wsManager.confirmSubscription(data.channel);
|
||||
console.log('Successfully subscribed to channel:', data.channel, 'Total subscriptions:', subscribedChannelsRef.current.size);
|
||||
} else if (data.type === 'subscription_error' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel); // Remove from pending
|
||||
subscribedChannelsRef.current.delete(data.channel); // Remove from confirmed (if it was there)
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.delete(data.channel);
|
||||
wsManager.failSubscription(data.channel);
|
||||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||||
// If it's the job channel, this is a critical error
|
||||
if (data.channel === `job:${job.id}`) {
|
||||
if (job && job.id && data.channel === `job:${job.id}`) {
|
||||
console.error('Failed to subscribe to job channel - job may not exist or access denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle job channel messages
|
||||
// Check both explicit channel and job_id match (for backwards compatibility)
|
||||
// Guard against undefined job.id
|
||||
if (!job || !job.id) {
|
||||
return;
|
||||
}
|
||||
const isJobChannel = data.channel === `job:${job.id}` ||
|
||||
(data.job_id === job.id && !data.channel);
|
||||
if (isJobChannel) {
|
||||
@@ -449,7 +451,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -465,8 +467,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -488,13 +489,62 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}, 100);
|
||||
return prevArray;
|
||||
});
|
||||
} else if (data.type === 'task_reset') {
|
||||
// Handle task_reset - task was reset to pending, steps and logs were cleared
|
||||
const taskId = data.task_id || (data.data && (data.data.id || data.data.task_id));
|
||||
console.log('Task reset received:', { task_id: taskId, data: data.data });
|
||||
|
||||
if (!taskId) {
|
||||
console.warn('task_reset message missing task_id:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update task in list
|
||||
setTasks(prev => {
|
||||
const prevArray = Array.isArray(prev) ? prev : [];
|
||||
const index = prevArray.findIndex(t => t.id === taskId);
|
||||
|
||||
if (index >= 0) {
|
||||
const updated = [...prevArray];
|
||||
const oldTask = updated[index];
|
||||
const newTask = {
|
||||
...oldTask,
|
||||
status: data.data?.status || 'pending',
|
||||
runner_id: null,
|
||||
current_step: null,
|
||||
started_at: null,
|
||||
error_message: data.data?.error_message || null,
|
||||
retry_count: data.data?.retry_count !== undefined ? data.data.retry_count : oldTask.retry_count,
|
||||
};
|
||||
updated[index] = newTask;
|
||||
console.log('Reset task at index', index, { task_id: taskId, new_task: newTask });
|
||||
return updated;
|
||||
}
|
||||
return prevArray;
|
||||
});
|
||||
|
||||
// Clear steps and logs for this task if flags indicate they were cleared
|
||||
if (data.data?.steps_cleared || data.data?.logs_cleared) {
|
||||
setTaskData(prev => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
steps: data.data?.steps_cleared ? [] : current.steps,
|
||||
logs: data.data?.logs_cleared ? [] : current.logs,
|
||||
lastId: 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'task_added' && data.data) {
|
||||
// New task was added - reload task summaries to get the new task
|
||||
console.log('task_added message received, reloading tasks...', data);
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -510,8 +560,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -534,7 +583,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -550,8 +599,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -738,48 +786,35 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
setExpandedTasks(newExpanded);
|
||||
};
|
||||
|
||||
const toggleStep = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const newExpanded = new Set(expandedSteps);
|
||||
if (newExpanded.has(key)) {
|
||||
newExpanded.delete(key);
|
||||
} else {
|
||||
newExpanded.add(key);
|
||||
// Initialize auto-scroll to true (default: on) when step is first expanded
|
||||
if (shouldAutoScrollRefs.current[key] === undefined) {
|
||||
shouldAutoScrollRefs.current[key] = true;
|
||||
}
|
||||
}
|
||||
setExpandedSteps(newExpanded);
|
||||
};
|
||||
|
||||
const toggleAutoScroll = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const toggleAutoScroll = (taskId, containerName) => {
|
||||
const key = `${taskId}-${containerName}`;
|
||||
// Toggle auto-scroll state (default to true if undefined)
|
||||
const currentState = shouldAutoScrollRefs.current[key] !== false;
|
||||
shouldAutoScrollRefs.current[key] = !currentState;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
// We don't have expandedSteps anymore, so just trigger a re-render by updating a dummy state
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
};
|
||||
|
||||
const handleLogWheel = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const handleLogWheel = (taskId, containerName) => {
|
||||
const key = `${taskId}-${containerName}`;
|
||||
// Turn off auto-scroll when user scrolls with wheel
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
shouldAutoScrollRefs.current[key] = false;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogClick = (taskId, stepName, e) => {
|
||||
const handleLogClick = (taskId, containerName, e) => {
|
||||
// Pause on left or right click
|
||||
if (e.button === 0 || e.button === 2) {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const key = `${taskId}-${containerName}`;
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
shouldAutoScrollRefs.current[key] = false;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -838,13 +873,23 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const outputFiles = files.filter((f) => f.file_type === 'output');
|
||||
const inputFiles = files.filter((f) => f.file_type === 'input');
|
||||
|
||||
// Helper to check if a file is an image
|
||||
// Helper to check if a file is a browser-supported image (or EXR which we convert server-side)
|
||||
const isImageFile = (fileName) => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
|
||||
// Browser-supported image formats + EXR (converted server-side)
|
||||
const imageExtensions = [
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg',
|
||||
'.ico', '.avif', '.apng', '.jfif', '.pjpeg', '.pjp',
|
||||
'.exr' // EXR files are converted to PNG server-side
|
||||
];
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerName.endsWith(ext));
|
||||
};
|
||||
|
||||
// Helper to check if a file is an EXR file
|
||||
const isEXRFile = (fileName) => {
|
||||
return fileName.toLowerCase().endsWith('.exr');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Image Preview Modal */}
|
||||
@@ -887,6 +932,32 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Preview Modal */}
|
||||
{previewVideo && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-[60] p-4"
|
||||
onClick={() => setPreviewVideo(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 rounded-lg shadow-xl max-w-5xl w-full max-h-[95vh] overflow-auto border border-gray-700 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gray-900 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold text-gray-100">{previewVideo.fileName}</h3>
|
||||
<button
|
||||
onClick={() => setPreviewVideo(null)}
|
||||
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 bg-black">
|
||||
<VideoPlayer videoUrl={previewVideo.url} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-700">
|
||||
<div className="sticky top-0 bg-gray-800 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
|
||||
@@ -940,15 +1011,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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
|
||||
</h3>
|
||||
<VideoPlayer videoUrl={videoUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
@@ -976,9 +1038,15 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
files={outputFiles}
|
||||
onDownload={handleDownload}
|
||||
onPreview={(file) => {
|
||||
const imageUrl = jobs.downloadFile(job.id, file.id);
|
||||
// Use EXR preview endpoint for EXR files, regular download for others
|
||||
const imageUrl = isEXRFile(file.file_name)
|
||||
? jobs.previewEXR(job.id, file.id)
|
||||
: jobs.downloadFile(job.id, file.id);
|
||||
setPreviewImage({ url: imageUrl, fileName: file.file_name });
|
||||
}}
|
||||
onVideoPreview={(file) => {
|
||||
setPreviewVideo({ url: jobs.getVideoUrl(job.id), fileName: file.file_name });
|
||||
}}
|
||||
isImageFile={isImageFile}
|
||||
/>
|
||||
</div>
|
||||
@@ -997,15 +1065,8 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const taskInfo = taskData[task.id] || { steps: [], logs: [] };
|
||||
const { steps, logs } = taskInfo;
|
||||
|
||||
// Group logs by step_name
|
||||
const logsByStep = {};
|
||||
logs.forEach(log => {
|
||||
const stepName = log.step_name || 'general';
|
||||
if (!logsByStep[stepName]) {
|
||||
logsByStep[stepName] = [];
|
||||
}
|
||||
logsByStep[stepName].push(log);
|
||||
});
|
||||
// Sort all logs chronologically (no grouping by step_name)
|
||||
const sortedLogs = [...logs].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
return (
|
||||
<div key={task.id} className="bg-gray-900 rounded-lg border border-gray-700">
|
||||
@@ -1022,9 +1083,9 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
{task.status}
|
||||
</span>
|
||||
<span className="font-medium text-gray-100">
|
||||
{task.task_type === 'metadata' ? 'Metadata Extraction' : `Frame ${task.frame_start}${task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}`}
|
||||
{task.task_type === 'encode' ? `Encode (${jobDetails.frame_start} - ${jobDetails.frame_end})` : `Frame ${task.frame}`}
|
||||
</span>
|
||||
{task.task_type && task.task_type !== 'render' && (
|
||||
{task.task_type && task.task_type !== 'render' && task.task_type !== 'encode' && (
|
||||
<span className="text-xs text-gray-400">({task.task_type})</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1033,153 +1094,46 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Content (Steps and Logs) */}
|
||||
{/* Task Content (Continuous Log Stream) */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* General logs (logs without step_name) */}
|
||||
{logsByStep['general'] && logsByStep['general'].length > 0 && (() => {
|
||||
const generalKey = `${task.id}-general`;
|
||||
const isGeneralExpanded = expandedSteps.has(generalKey);
|
||||
const generalLogs = logsByStep['general'];
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div
|
||||
onClick={() => toggleStep(task.id, 'general')}
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isGeneralExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className="font-medium text-gray-100">General</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{generalLogs.length} log{generalLogs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isGeneralExpanded && (
|
||||
<div className="p-3 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Logs</span>
|
||||
<button
|
||||
onClick={() => toggleAutoScroll(task.id, 'general')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
shouldAutoScrollRefs.current[generalKey] !== false
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
|
||||
} transition-colors`}
|
||||
title={shouldAutoScrollRefs.current[generalKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
>
|
||||
{shouldAutoScrollRefs.current[generalKey] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
</button>
|
||||
{/* Header with auto-scroll */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
</div>
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) {
|
||||
logContainerRefs.current[generalKey] = el;
|
||||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||||
if (shouldAutoScrollRefs.current[generalKey] === undefined) {
|
||||
shouldAutoScrollRefs.current[generalKey] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onWheel={() => handleLogWheel(task.id, 'general')}
|
||||
onMouseDown={(e) => handleLogClick(task.id, 'general', e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, 'general', e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
|
||||
>
|
||||
{generalLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
[{new Date(log.created_at).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span className="ml-2">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Steps */}
|
||||
{steps.length > 0 ? (
|
||||
steps.map((step) => {
|
||||
const stepKey = `${task.id}-${step.step_name}`;
|
||||
const isStepExpanded = expandedSteps.has(stepKey);
|
||||
const stepLogs = logsByStep[step.step_name] || [];
|
||||
|
||||
return (
|
||||
<div key={step.id} className="bg-gray-800 rounded-lg border border-gray-700">
|
||||
{/* Step Header */}
|
||||
<div
|
||||
onClick={() => toggleStep(task.id, step.step_name)}
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isStepExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getStepStatusIcon(step.status)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-100">{step.step_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{step.duration_ms && (
|
||||
<span className="text-sm text-gray-400">
|
||||
{(step.duration_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
)}
|
||||
{stepLogs.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{stepLogs.length} log{stepLogs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Logs */}
|
||||
{isStepExpanded && (
|
||||
<div className="p-3 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Logs</span>
|
||||
<button
|
||||
onClick={() => toggleAutoScroll(task.id, step.step_name)}
|
||||
onClick={() => toggleAutoScroll(task.id, 'logs')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
shouldAutoScrollRefs.current[stepKey] !== false
|
||||
shouldAutoScrollRefs.current[`${task.id}-logs`] !== false
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
|
||||
} transition-colors`}
|
||||
title={shouldAutoScrollRefs.current[stepKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
title={shouldAutoScrollRefs.current[`${task.id}-logs`] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
>
|
||||
{shouldAutoScrollRefs.current[stepKey] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
{shouldAutoScrollRefs.current[`${task.id}-logs`] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) {
|
||||
logContainerRefs.current[stepKey] = el;
|
||||
logContainerRefs.current[`${task.id}-logs`] = el;
|
||||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||||
if (shouldAutoScrollRefs.current[stepKey] === undefined) {
|
||||
shouldAutoScrollRefs.current[stepKey] = true;
|
||||
if (shouldAutoScrollRefs.current[`${task.id}-logs`] === undefined) {
|
||||
shouldAutoScrollRefs.current[`${task.id}-logs`] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onWheel={() => handleLogWheel(task.id, step.step_name)}
|
||||
onMouseDown={(e) => handleLogClick(task.id, step.step_name, e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, step.step_name, e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
|
||||
onWheel={() => handleLogWheel(task.id, 'logs')}
|
||||
onMouseDown={(e) => handleLogClick(task.id, 'logs', e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, 'logs', e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-96 overflow-y-auto"
|
||||
>
|
||||
{stepLogs.length === 0 ? (
|
||||
{sortedLogs.length === 0 ? (
|
||||
<p className="text-gray-500">No logs yet...</p>
|
||||
) : (
|
||||
stepLogs.map((log) => (
|
||||
sortedLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
@@ -1192,16 +1146,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
logsByStep['general'] && logsByStep['general'].length > 0 ? null : (
|
||||
<p className="text-gray-400 text-sm">No steps yet...</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,12 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_start: 1,
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
allow_parallel_runners: true,
|
||||
render_settings: null, // Will contain engine settings
|
||||
unhide_objects: false, // Unhide objects/collections tweak
|
||||
enable_execution: false, // Enable auto-execution in Blender
|
||||
blender_version: '', // Blender version override (empty = auto-detect)
|
||||
preserve_hdr: false, // Preserve HDR range for EXR encoding
|
||||
preserve_alpha: false, // Preserve alpha channel for EXR encoding
|
||||
});
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
@@ -32,6 +34,8 @@ export default function JobSubmission({ onSuccess }) {
|
||||
const [selectedMainBlend, setSelectedMainBlend] = useState('');
|
||||
const [confirmedMissingFiles, setConfirmedMissingFiles] = useState(false); // Confirmation for missing files
|
||||
const [uploadTimeRemaining, setUploadTimeRemaining] = useState(null); // Estimated time remaining in seconds
|
||||
const [blenderVersions, setBlenderVersions] = useState([]); // Available Blender versions from server
|
||||
const [loadingBlenderVersions, setLoadingBlenderVersions] = useState(false);
|
||||
|
||||
// Use refs to track cancellation state across re-renders
|
||||
const isCancelledRef = useRef(false);
|
||||
@@ -72,6 +76,25 @@ export default function JobSubmission({ onSuccess }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch available Blender versions on mount
|
||||
useEffect(() => {
|
||||
const fetchBlenderVersions = async () => {
|
||||
setLoadingBlenderVersions(true);
|
||||
try {
|
||||
const response = await fetch('/api/blender/versions');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBlenderVersions(data.versions || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Blender versions:', err);
|
||||
} finally {
|
||||
setLoadingBlenderVersions(false);
|
||||
}
|
||||
};
|
||||
fetchBlenderVersions();
|
||||
}, []);
|
||||
|
||||
// Connect to shared WebSocket on mount
|
||||
useEffect(() => {
|
||||
listenerIdRef.current = wsManager.subscribe('jobsubmission', {
|
||||
@@ -79,14 +102,16 @@ export default function JobSubmission({ onSuccess }) {
|
||||
console.log('JobSubmission: Shared WebSocket connected');
|
||||
},
|
||||
message: (data) => {
|
||||
// Handle subscription responses
|
||||
// Handle subscription responses - update both local refs and wsManager
|
||||
if (data.type === 'subscribed' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.add(data.channel);
|
||||
wsManager.confirmSubscription(data.channel);
|
||||
console.log('Successfully subscribed to channel:', data.channel);
|
||||
} else if (data.type === 'subscription_error' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.delete(data.channel);
|
||||
wsManager.failSubscription(data.channel);
|
||||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||||
// If it's the upload channel we're trying to subscribe to, show error
|
||||
if (data.channel.startsWith('upload:')) {
|
||||
@@ -94,52 +119,7 @@ export default function JobSubmission({ onSuccess }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upload progress messages
|
||||
if (data.channel && data.channel.startsWith('upload:') && subscribedChannelsRef.current.has(data.channel)) {
|
||||
if (data.type === 'upload_progress' || data.type === 'processing_status') {
|
||||
const progress = data.data?.progress || 0;
|
||||
const status = data.data?.status || 'uploading';
|
||||
const message = data.data?.message || '';
|
||||
|
||||
setUploadProgress(progress);
|
||||
|
||||
// Calculate time remaining for upload progress
|
||||
if (status === 'uploading' && progress > 0 && progress < 100) {
|
||||
if (!uploadStartTimeRef.current) {
|
||||
uploadStartTimeRef.current = Date.now();
|
||||
}
|
||||
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
|
||||
const remaining = (elapsed / progress) * (100 - progress);
|
||||
setUploadTimeRemaining(remaining);
|
||||
} else if (status === 'completed' || status === 'error') {
|
||||
setUploadTimeRemaining(null);
|
||||
uploadStartTimeRef.current = null;
|
||||
}
|
||||
|
||||
if (status === 'uploading') {
|
||||
setMetadataStatus('extracting');
|
||||
} else if (status === 'processing' || status === 'extracting_zip' || status === 'extracting_metadata' || status === 'creating_context') {
|
||||
setMetadataStatus('processing');
|
||||
// Reset time remaining for processing phase
|
||||
setUploadTimeRemaining(null);
|
||||
} else if (status === 'completed') {
|
||||
setMetadataStatus('completed');
|
||||
setIsUploading(false);
|
||||
setUploadTimeRemaining(null);
|
||||
uploadStartTimeRef.current = null;
|
||||
// Unsubscribe from upload channel
|
||||
unsubscribeFromUploadChannel(data.channel);
|
||||
} else if (status === 'error') {
|
||||
setMetadataStatus('error');
|
||||
setIsUploading(false);
|
||||
setUploadTimeRemaining(null);
|
||||
uploadStartTimeRef.current = null;
|
||||
setError(message || 'Upload/processing failed');
|
||||
// Unsubscribe from upload channel
|
||||
unsubscribeFromUploadChannel(data.channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upload progress is now handled via HTTP response - no WebSocket messages needed
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('JobSubmission: Shared WebSocket error:', error);
|
||||
@@ -166,13 +146,10 @@ export default function JobSubmission({ onSuccess }) {
|
||||
|
||||
// Helper function to unsubscribe from upload channel
|
||||
const unsubscribeFromUploadChannel = (channel) => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
return; // Not subscribed
|
||||
}
|
||||
wsManager.send({ type: 'unsubscribe', channel });
|
||||
wsManager.unsubscribeFromChannel(channel);
|
||||
subscribedChannelsRef.current.delete(channel);
|
||||
pendingSubscriptionsRef.current.delete(channel);
|
||||
console.log('Unsubscribed from upload channel:', channel);
|
||||
@@ -180,11 +157,8 @@ export default function JobSubmission({ onSuccess }) {
|
||||
|
||||
// Helper function to unsubscribe from all channels
|
||||
const unsubscribeFromAllChannels = () => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
subscribedChannelsRef.current.forEach(channel => {
|
||||
wsManager.send({ type: 'unsubscribe', channel });
|
||||
wsManager.unsubscribeFromChannel(channel);
|
||||
});
|
||||
subscribedChannelsRef.current.clear();
|
||||
pendingSubscriptionsRef.current.clear();
|
||||
@@ -223,40 +197,40 @@ export default function JobSubmission({ onSuccess }) {
|
||||
uploadStartTimeRef.current = Date.now();
|
||||
setMetadataStatus('extracting');
|
||||
|
||||
// Upload file to new endpoint (no job required)
|
||||
// Upload file and get metadata in HTTP response
|
||||
const result = await jobs.uploadFileForJobCreation(selectedFile, (progress) => {
|
||||
// XHR progress as fallback, but WebSocket is primary
|
||||
// Show upload progress during upload
|
||||
setUploadProgress(progress);
|
||||
// Calculate time remaining for XHR progress
|
||||
// Calculate time remaining for upload progress
|
||||
if (progress > 0 && progress < 100 && uploadStartTimeRef.current) {
|
||||
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
|
||||
const remaining = (elapsed / progress) * (100 - progress);
|
||||
setUploadTimeRemaining(remaining);
|
||||
} else if (progress >= 100) {
|
||||
// Upload complete - switch to processing status
|
||||
setUploadProgress(100);
|
||||
setMetadataStatus('processing');
|
||||
setUploadTimeRemaining(null);
|
||||
}
|
||||
}, selectedMainBlend || undefined);
|
||||
|
||||
// Store session ID for later use when creating the job
|
||||
if (result.session_id) {
|
||||
setUploadSessionId(result.session_id);
|
||||
|
||||
// Subscribe to upload progress channel
|
||||
if (wsManager.getReadyState() === WebSocket.OPEN) {
|
||||
const channel = `upload:${result.session_id}`;
|
||||
wsManager.send({ type: 'subscribe', channel });
|
||||
// Don't set subscribedUploadChannelRef yet - wait for confirmation
|
||||
console.log('Subscribing to upload channel:', channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ZIP extraction found multiple blend files
|
||||
if (result.zip_extracted && result.blend_files && result.blend_files.length > 1) {
|
||||
setBlendFiles(result.blend_files);
|
||||
// Upload and processing complete - metadata is in the response
|
||||
setIsUploading(false);
|
||||
setUploadProgress(100);
|
||||
setUploadTimeRemaining(null);
|
||||
uploadStartTimeRef.current = null;
|
||||
|
||||
// Handle ZIP extraction results - multiple blend files found
|
||||
if (result.status === 'select_blend' || (result.zip_extracted && result.blend_files && result.blend_files.length > 1)) {
|
||||
setBlendFiles(result.blend_files || []);
|
||||
setMetadataStatus('select_blend');
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload and processing complete
|
||||
setIsUploading(false);
|
||||
|
||||
// If metadata was extracted, use it
|
||||
if (result.metadata_extracted && result.metadata) {
|
||||
@@ -286,6 +260,7 @@ export default function JobSubmission({ onSuccess }) {
|
||||
...result.metadata.render_settings,
|
||||
engine_settings: result.metadata.render_settings.engine_settings || {},
|
||||
} : null,
|
||||
blender_version: result.metadata.blender_version || prev.blender_version,
|
||||
}));
|
||||
} else {
|
||||
setMetadataStatus('error');
|
||||
@@ -323,36 +298,30 @@ export default function JobSubmission({ onSuccess }) {
|
||||
|
||||
// Re-upload with selected main blend file
|
||||
const result = await jobs.uploadFileForJobCreation(file, (progress) => {
|
||||
// XHR progress as fallback, but WebSocket is primary
|
||||
// Show upload progress during upload
|
||||
setUploadProgress(progress);
|
||||
// Calculate time remaining for XHR progress
|
||||
// Calculate time remaining for upload progress
|
||||
if (progress > 0 && progress < 100 && uploadStartTimeRef.current) {
|
||||
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
|
||||
const remaining = (elapsed / progress) * (100 - progress);
|
||||
setUploadTimeRemaining(remaining);
|
||||
} else if (progress >= 100) {
|
||||
// Upload complete - switch to processing status
|
||||
setUploadProgress(100);
|
||||
setMetadataStatus('processing');
|
||||
setUploadTimeRemaining(null);
|
||||
}
|
||||
}, selectedMainBlend);
|
||||
|
||||
setBlendFiles([]);
|
||||
|
||||
// Store session ID and subscribe to upload progress
|
||||
// Store session ID
|
||||
if (result.session_id) {
|
||||
setUploadSessionId(result.session_id);
|
||||
|
||||
// Subscribe to upload progress channel
|
||||
if (wsManager.getReadyState() === WebSocket.OPEN) {
|
||||
const channel = `upload:${result.session_id}`;
|
||||
// Don't subscribe if already subscribed or pending
|
||||
if (!subscribedChannelsRef.current.has(channel) && !pendingSubscriptionsRef.current.has(channel)) {
|
||||
wsManager.send({ type: 'subscribe', channel });
|
||||
pendingSubscriptionsRef.current.add(channel);
|
||||
console.log('Subscribing to upload channel:', channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload and processing complete
|
||||
setIsUploading(false);
|
||||
// Upload and processing complete - metadata is in the response
|
||||
setIsUploading(false);
|
||||
|
||||
// If metadata was extracted, use it
|
||||
if (result.metadata_extracted && result.metadata) {
|
||||
@@ -382,6 +351,7 @@ export default function JobSubmission({ onSuccess }) {
|
||||
...result.metadata.render_settings,
|
||||
engine_settings: result.metadata.render_settings.engine_settings || {},
|
||||
} : null,
|
||||
blender_version: result.metadata.blender_version || prev.blender_version,
|
||||
}));
|
||||
} else {
|
||||
setMetadataStatus('error');
|
||||
@@ -477,11 +447,13 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_start: parseInt(formData.frame_start),
|
||||
frame_end: parseInt(formData.frame_end),
|
||||
output_format: formData.output_format,
|
||||
allow_parallel_runners: formData.allow_parallel_runners,
|
||||
render_settings: renderSettings,
|
||||
upload_session_id: uploadSessionId || undefined, // Pass session ID to move context archive
|
||||
unhide_objects: formData.unhide_objects || undefined, // Pass unhide toggle
|
||||
enable_execution: formData.enable_execution || undefined, // Pass enable execution toggle
|
||||
preserve_hdr: formData.preserve_hdr || undefined, // Pass preserve HDR toggle
|
||||
preserve_alpha: formData.preserve_alpha || undefined, // Pass preserve alpha toggle
|
||||
blender_version: formData.blender_version || undefined, // Pass Blender version override
|
||||
});
|
||||
|
||||
// Fetch the full job details
|
||||
@@ -508,10 +480,12 @@ export default function JobSubmission({ onSuccess }) {
|
||||
frame_start: 1,
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
allow_parallel_runners: true,
|
||||
render_settings: null,
|
||||
unhide_objects: false,
|
||||
enable_execution: false,
|
||||
blender_version: '',
|
||||
preserve_hdr: false,
|
||||
preserve_alpha: false,
|
||||
});
|
||||
setShowAdvancedSettings(false);
|
||||
formatManuallyChangedRef.current = false;
|
||||
@@ -534,6 +508,7 @@ export default function JobSubmission({ onSuccess }) {
|
||||
render_settings: null,
|
||||
unhide_objects: false,
|
||||
enable_execution: false,
|
||||
blender_version: '',
|
||||
});
|
||||
setShowAdvancedSettings(false);
|
||||
setFile(null);
|
||||
@@ -672,20 +647,9 @@ export default function JobSubmission({ onSuccess }) {
|
||||
</div>
|
||||
</div>
|
||||
) : metadataStatus === 'processing' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>Processing file and extracting metadata...</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-orange-400/80 mt-1">
|
||||
This may take a moment for large files...
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-500"></div>
|
||||
<span>Processing file and extracting metadata...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -868,20 +832,35 @@ export default function JobSubmission({ onSuccess }) {
|
||||
<option value="EXR">EXR</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>
|
||||
<option value="EXR_VP9_WEBM">EXR_VP9_WEBM (High Quality Video With Alpha & HDR)</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-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
|
||||
/>
|
||||
<label htmlFor="allow_parallel_runners" className="ml-2 block text-sm text-gray-300">
|
||||
Allow multiple runners to work on this job simultaneously
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Blender Version
|
||||
{metadata?.blender_version && (
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
(detected: {metadata.blender_version})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={formData.blender_version}
|
||||
onChange={(e) => setFormData({ ...formData, blender_version: 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"
|
||||
disabled={loadingBlenderVersions}
|
||||
>
|
||||
<option value="">Auto-detect from blend file</option>
|
||||
{blenderVersions.map((v) => (
|
||||
<option key={v.full} value={v.full}>
|
||||
{v.full}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Override the Blender version used for rendering. Leave as auto-detect to use the version the file was saved with.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
|
||||
@@ -920,6 +899,53 @@ export default function JobSubmission({ onSuccess }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(formData.output_format === 'EXR_264_MP4' || formData.output_format === 'EXR_AV1_MP4' || formData.output_format === 'EXR_VP9_WEBM') && (
|
||||
<>
|
||||
<div className="p-3 bg-blue-400/10 border border-blue-400/30 rounded-lg mb-2">
|
||||
<p className="text-xs text-blue-400">
|
||||
<strong>Note:</strong> The preserve options below allow you to explicitly control HDR and alpha preservation. If autodetection finds HDR content or alpha channels in your EXR files, they will be automatically preserved even if these options are unchecked. <strong>Important:</strong> Alpha detection only checks the first frame, so if your render uses transparency later in the sequence, you should explicitly enable the preserve alpha option. HDR detection is not perfect and may miss some HDR content, so if you're certain your render contains HDR content, you should explicitly enable the preserve HDR option.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="preserve_hdr"
|
||||
checked={formData.preserve_hdr}
|
||||
onChange={(e) => setFormData({ ...formData, preserve_hdr: e.target.checked })}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
|
||||
/>
|
||||
<label htmlFor="preserve_hdr" className="ml-2 block text-sm text-gray-300">
|
||||
<span className="font-medium">Preserve HDR range</span>
|
||||
<span className="text-xs text-gray-400 block mt-1">
|
||||
Explicitly enable HDR preservation with HLG transfer function. Works on both HDR and SDR displays. HDR content will be automatically detected and preserved if present, but detection may miss some content. If you're certain your render contains HDR, enable this option.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(formData.output_format === 'EXR_AV1_MP4' || formData.output_format === 'EXR_VP9_WEBM') && (
|
||||
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="preserve_alpha"
|
||||
checked={formData.preserve_alpha}
|
||||
onChange={(e) => setFormData({ ...formData, preserve_alpha: e.target.checked })}
|
||||
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
|
||||
/>
|
||||
<label htmlFor="preserve_alpha" className="ml-2 block text-sm text-gray-300">
|
||||
<span className="font-medium">Preserve alpha channel</span>
|
||||
<span className="text-xs text-gray-400 block mt-1">
|
||||
Explicitly enable alpha channel encoding. Only available for AV1 and VP9 codecs. Alpha channels will be automatically detected and preserved if present in the first frame. Enable this if your render uses transparency later in the sequence.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata && metadataStatus === 'completed' && (
|
||||
<>
|
||||
<div className="p-4 bg-green-400/20 border border-green-400/50 rounded-lg text-sm mb-4">
|
||||
|
||||
@@ -376,6 +376,10 @@ export const jobs = {
|
||||
return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`;
|
||||
},
|
||||
|
||||
previewEXR(jobId, fileId) {
|
||||
return `${API_BASE}/jobs/${jobId}/files/${fileId}/preview-exr`;
|
||||
},
|
||||
|
||||
getVideoUrl(jobId) {
|
||||
return `${API_BASE}/jobs/${jobId}/video`;
|
||||
},
|
||||
|
||||
@@ -10,6 +10,11 @@ class WebSocketManager {
|
||||
this.isConnecting = false;
|
||||
this.listenerIdCounter = 0;
|
||||
this.verboseLogging = false; // Set to true to enable verbose WebSocket logging
|
||||
|
||||
// Track server-side channel subscriptions for re-subscription on reconnect
|
||||
this.serverSubscriptions = new Set(); // Channels we want to be subscribed to
|
||||
this.confirmedSubscriptions = new Set(); // Channels confirmed by server
|
||||
this.pendingSubscriptions = new Set(); // Channels waiting for confirmation
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -37,6 +42,10 @@ class WebSocketManager {
|
||||
console.log('Shared WebSocket connected');
|
||||
}
|
||||
this.isConnecting = false;
|
||||
|
||||
// Re-subscribe to all channels that were previously subscribed
|
||||
this.resubscribeToChannels();
|
||||
|
||||
this.notifyListeners('open', {});
|
||||
};
|
||||
|
||||
@@ -68,17 +77,24 @@ class WebSocketManager {
|
||||
}
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
|
||||
// Clear confirmed/pending but keep serverSubscriptions for re-subscription
|
||||
this.confirmedSubscriptions.clear();
|
||||
this.pendingSubscriptions.clear();
|
||||
|
||||
this.notifyListeners('close', event);
|
||||
|
||||
// Always retry connection
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.connect();
|
||||
// Always retry connection if we have listeners
|
||||
if (this.listeners.size > 0) {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.connect();
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
@@ -159,6 +175,81 @@ class WebSocketManager {
|
||||
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
// Subscribe to a server-side channel (will be re-subscribed on reconnect)
|
||||
subscribeToChannel(channel) {
|
||||
if (this.serverSubscriptions.has(channel)) {
|
||||
// Already subscribed or pending
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverSubscriptions.add(channel);
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
if (!this.confirmedSubscriptions.has(channel) && !this.pendingSubscriptions.has(channel)) {
|
||||
this.pendingSubscriptions.add(channel);
|
||||
this.send({ type: 'subscribe', channel });
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Subscribing to channel:', channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from a server-side channel (won't be re-subscribed on reconnect)
|
||||
unsubscribeFromChannel(channel) {
|
||||
this.serverSubscriptions.delete(channel);
|
||||
this.confirmedSubscriptions.delete(channel);
|
||||
this.pendingSubscriptions.delete(channel);
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.send({ type: 'unsubscribe', channel });
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Unsubscribing from channel:', channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a channel subscription as confirmed (call this when server confirms)
|
||||
confirmSubscription(channel) {
|
||||
this.pendingSubscriptions.delete(channel);
|
||||
this.confirmedSubscriptions.add(channel);
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Subscription confirmed for channel:', channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a channel subscription as failed (call this when server rejects)
|
||||
failSubscription(channel) {
|
||||
this.pendingSubscriptions.delete(channel);
|
||||
this.serverSubscriptions.delete(channel);
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Subscription failed for channel:', channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if subscribed to a channel
|
||||
isSubscribedToChannel(channel) {
|
||||
return this.confirmedSubscriptions.has(channel);
|
||||
}
|
||||
|
||||
// Re-subscribe to all channels after reconnect
|
||||
resubscribeToChannels() {
|
||||
if (this.serverSubscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.verboseLogging) {
|
||||
console.log('WebSocketManager: Re-subscribing to channels:', Array.from(this.serverSubscriptions));
|
||||
}
|
||||
|
||||
for (const channel of this.serverSubscriptions) {
|
||||
if (!this.pendingSubscriptions.has(channel)) {
|
||||
this.pendingSubscriptions.add(channel);
|
||||
this.send({ type: 'subscribe', channel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
@@ -169,6 +260,9 @@ class WebSocketManager {
|
||||
this.ws = null;
|
||||
}
|
||||
this.listeners.clear();
|
||||
this.serverSubscriptions.clear();
|
||||
this.confirmedSubscriptions.clear();
|
||||
this.pendingSubscriptions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user