Enhance image processing and progress tracking in ImageQueueModal; update docker-compose for read-write access
This commit is contained in:
@@ -16,6 +16,7 @@ export interface ImageQueueItem {
|
||||
type: 'featured' | 'in_article';
|
||||
position?: number;
|
||||
contentTitle: string;
|
||||
prompt?: string; // Image prompt text
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
imageUrl: string | null;
|
||||
@@ -67,55 +68,147 @@ export default function ImageQueueModal({
|
||||
}
|
||||
}, [localQueue, onUpdateQueue]);
|
||||
|
||||
// Smooth progress animation (like reference plugin)
|
||||
// Time-based progress: 1% → 50% in 5s, then 2% every 200ms until 80%, then event-based to 100%
|
||||
useEffect(() => {
|
||||
// Start smooth progress for items that are processing
|
||||
localQueue.forEach((item) => {
|
||||
if (item.status === 'processing' && item.progress < 95) {
|
||||
// Only start animation if not already running
|
||||
if (!progressIntervalsRef.current[item.index]) {
|
||||
// Start from current progress or 0
|
||||
let currentProgress = smoothProgress[item.index] || item.progress || 0;
|
||||
let phase = currentProgress < 50 ? 1 : currentProgress < 75 ? 2 : 3;
|
||||
let phaseStartTime = Date.now();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - phaseStartTime;
|
||||
|
||||
if (phase === 1 && currentProgress < 50) {
|
||||
// Phase 1: 0% to 50% in 7 seconds (7.14% per second)
|
||||
currentProgress += 0.714;
|
||||
if (currentProgress >= 50 || elapsed >= 7000) {
|
||||
currentProgress = 50;
|
||||
phase = 2;
|
||||
phaseStartTime = Date.now();
|
||||
}
|
||||
} else if (phase === 2 && currentProgress < 75) {
|
||||
// Phase 2: 50% to 75% in 5 seconds (5% per second)
|
||||
currentProgress += 0.5;
|
||||
if (currentProgress >= 75 || elapsed >= 5000) {
|
||||
currentProgress = 75;
|
||||
phase = 3;
|
||||
phaseStartTime = Date.now();
|
||||
}
|
||||
} else if (phase === 3 && currentProgress < 95) {
|
||||
// Phase 3: 75% to 95% - 5% every second
|
||||
if (elapsed >= 1000) {
|
||||
currentProgress = Math.min(95, currentProgress + 5);
|
||||
phaseStartTime = Date.now();
|
||||
}
|
||||
// Helper function for Phase 2: 2% every 200ms until 80%
|
||||
const startPhase2Animation = (index: number, startProgress: number) => {
|
||||
if (progressIntervalsRef.current[index]) {
|
||||
clearInterval(progressIntervalsRef.current[index]);
|
||||
delete progressIntervalsRef.current[index];
|
||||
}
|
||||
|
||||
let currentPercent = Math.max(startProgress, 50);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setSmoothProgress(prev => {
|
||||
const current = prev[index] ?? currentPercent;
|
||||
|
||||
// Only increment if still below 80% and status is processing or pending
|
||||
const item = localQueue.find(item => item.index === index);
|
||||
if (current < 80 && (item?.status === 'processing' || item?.status === 'pending')) {
|
||||
const newPercentage = Math.min(current + 2, 80); // 2% every 200ms
|
||||
currentPercent = newPercentage;
|
||||
|
||||
// Stop if we've reached 80%
|
||||
if (newPercentage >= 80) {
|
||||
clearInterval(interval);
|
||||
delete progressIntervalsRef.current[index];
|
||||
}
|
||||
|
||||
setSmoothProgress(prev => ({
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[item.index]: Math.round(currentProgress)
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
[index]: newPercentage
|
||||
};
|
||||
} else {
|
||||
// Stop if status changed or reached 80%
|
||||
clearInterval(interval);
|
||||
delete progressIntervalsRef.current[index];
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, 200); // 2% every 200ms
|
||||
|
||||
progressIntervalsRef.current[index] = interval;
|
||||
};
|
||||
|
||||
localQueue.forEach((item) => {
|
||||
// Apply progress logic to all items that are processing OR pending (waiting in queue)
|
||||
// This ensures all images start progress animation, not just the currently processing one
|
||||
if (item.status === 'processing' || item.status === 'pending') {
|
||||
// Initialize smoothProgress if not set (start at 1% for new items)
|
||||
const currentProgress = smoothProgress[item.index] ?? (item.progress > 0 ? item.progress : 1);
|
||||
|
||||
// Phase 1: 1% → 50% at 100ms per 1% (regardless of events)
|
||||
// Only start if we're below 50% and no interval is already running
|
||||
if (currentProgress < 50 && !progressIntervalsRef.current[item.index]) {
|
||||
let currentPercent = Math.max(currentProgress, 1); // Start from 1% or current if higher
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setSmoothProgress(prev => {
|
||||
const current = prev[item.index] ?? currentPercent;
|
||||
|
||||
// Only increment if still below 50% and status is processing or pending
|
||||
if (current < 50 && (item.status === 'processing' || item.status === 'pending')) {
|
||||
const newPercentage = Math.min(current + 1, 50); // 1% every 100ms
|
||||
currentPercent = newPercentage;
|
||||
|
||||
// Stop if we've reached 50%
|
||||
if (newPercentage >= 50) {
|
||||
clearInterval(interval);
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
|
||||
// Start Phase 2: 2% every 200ms until 80%
|
||||
if (newPercentage < 80) {
|
||||
startPhase2Animation(item.index, newPercentage);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[item.index]: newPercentage
|
||||
};
|
||||
} else {
|
||||
// Stop if status changed or reached 50%
|
||||
clearInterval(interval);
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, 200); // 1% every 100ms
|
||||
|
||||
progressIntervalsRef.current[item.index] = interval;
|
||||
}
|
||||
} else {
|
||||
// Stop animation if item is no longer processing
|
||||
// Phase 2: 2% every 200ms until 80%
|
||||
else if (currentProgress >= 50 && currentProgress < 80 && !progressIntervalsRef.current[item.index]) {
|
||||
startPhase2Animation(item.index, currentProgress);
|
||||
}
|
||||
// Phase 3: Event-based smooth completion to 100% (after image is downloaded and shown)
|
||||
// Check if image is ready (has imageUrl) even if still processing
|
||||
else if (item.imageUrl && currentProgress < 100 && !progressIntervalsRef.current[item.index]) {
|
||||
// Stop any existing animation
|
||||
if (progressIntervalsRef.current[item.index]) {
|
||||
clearInterval(progressIntervalsRef.current[item.index]);
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
}
|
||||
|
||||
// Smoothly complete to 100%
|
||||
if (!progressIntervalsRef.current[item.index]) {
|
||||
let animatingProgress = Math.max(currentProgress, 80); // Start from 80% or current if higher
|
||||
const startProgress = animatingProgress;
|
||||
const endProgress = 100;
|
||||
const duration = 800; // 500ms
|
||||
const startTime = Date.now();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(1, elapsed / duration);
|
||||
|
||||
// Ease-out quadratic
|
||||
const eased = 1 - Math.pow(1 - progress, 2);
|
||||
animatingProgress = startProgress + ((endProgress - startProgress) * eased);
|
||||
|
||||
if (animatingProgress >= 100 || elapsed >= duration) {
|
||||
animatingProgress = 100;
|
||||
clearInterval(interval);
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
}
|
||||
|
||||
setSmoothProgress(prev => ({
|
||||
...prev,
|
||||
[item.index]: Math.round(animatingProgress)
|
||||
}));
|
||||
}, 16);
|
||||
|
||||
progressIntervalsRef.current[item.index] = interval;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle non-processing items
|
||||
localQueue.forEach((item) => {
|
||||
if (item.status !== 'processing') {
|
||||
// Stop animation for completed/failed items
|
||||
if (progressIntervalsRef.current[item.index]) {
|
||||
clearInterval(progressIntervalsRef.current[item.index]);
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
@@ -130,7 +223,7 @@ export default function ImageQueueModal({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval));
|
||||
@@ -256,6 +349,28 @@ export default function ImageQueueModal({
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [isOpen, taskId]);
|
||||
|
||||
// Helper function to convert image_path to frontend-accessible URL
|
||||
const getImageUrlFromPath = (imagePath: string | null | undefined): string | null => {
|
||||
if (!imagePath) return null;
|
||||
|
||||
// If path contains 'ai-images', extract filename and convert to web URL
|
||||
if (imagePath.includes('ai-images')) {
|
||||
const filename = imagePath.split('ai-images/')[1] || imagePath.split('ai-images\\')[1];
|
||||
if (filename) {
|
||||
return `/images/ai-images/${filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If path is already a web path, return as-is
|
||||
if (imagePath.startsWith('/images/')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Otherwise, try to extract filename and use ai-images path
|
||||
const filename = imagePath.split('/').pop() || imagePath.split('\\').pop();
|
||||
return filename ? `/images/ai-images/${filename}` : null;
|
||||
};
|
||||
|
||||
const updateQueueFromTaskMeta = (meta: any) => {
|
||||
const { current_image, total_images, completed, failed, results, current_image_progress, current_image_id } = meta;
|
||||
|
||||
@@ -272,20 +387,25 @@ export default function ImageQueueModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Only set imageUrl if image is completed AND image_path exists (image is saved)
|
||||
const imageUrl = (result.status === 'completed' && result.image_path)
|
||||
? getImageUrlFromPath(result.image_path)
|
||||
: null;
|
||||
|
||||
// Use backend progress as target (0%, 50%, 75%, 90%, 100%)
|
||||
// This works for both featured and in-article images
|
||||
const backendProgress = (current_image_id === item.imageId && current_image_progress !== undefined)
|
||||
? current_image_progress
|
||||
: (result.status === 'completed' ? 100 :
|
||||
result.status === 'failed' ? 0 :
|
||||
item.progress);
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: result.status === 'completed' ? 'completed' :
|
||||
result.status === 'failed' ? 'failed' : 'processing',
|
||||
progress: result.status === 'completed' ? 100 :
|
||||
result.status === 'failed' ? 0 :
|
||||
// Use current_image_progress if this is the current image being processed
|
||||
// Otherwise use smooth progress animation
|
||||
(current_image_id === item.imageId && current_image_progress !== undefined) ? current_image_progress :
|
||||
index + 1 < current_image ? 100 :
|
||||
index + 1 === current_image ? (smoothProgress[item.index] || 0) : 0,
|
||||
imageUrl: result.image_path
|
||||
? `/api/v1/writer/images/${item.imageId}/file/`
|
||||
: (result.image_url || item.imageUrl),
|
||||
progress: backendProgress, // Use backend progress as target for smooth animation
|
||||
imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
@@ -299,11 +419,11 @@ export default function ImageQueueModal({
|
||||
}
|
||||
return { ...item, status: 'completed', progress: 100 };
|
||||
} else if (index + 1 === current_image || current_image_id === item.imageId) {
|
||||
// Currently processing - use current_image_progress if available, otherwise smooth progress
|
||||
const progress = (current_image_progress !== undefined && current_image_id === item.imageId)
|
||||
// Currently processing - use backend progress if available (works for featured and in-article)
|
||||
const backendProgress = (current_image_progress !== undefined && current_image_id === item.imageId)
|
||||
? current_image_progress
|
||||
: (smoothProgress[item.index] || 0);
|
||||
return { ...item, status: 'processing', progress };
|
||||
: (smoothProgress[item.index] ?? 0);
|
||||
return { ...item, status: 'processing', progress: backendProgress };
|
||||
}
|
||||
|
||||
return item;
|
||||
@@ -325,13 +445,16 @@ export default function ImageQueueModal({
|
||||
delete progressIntervalsRef.current[item.index];
|
||||
}
|
||||
|
||||
// Only set imageUrl if image is completed AND image_path exists (image is saved)
|
||||
const imageUrl = (taskResult.status === 'completed' && taskResult.image_path)
|
||||
? getImageUrlFromPath(taskResult.image_path)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: taskResult.status === 'completed' ? 'completed' : 'failed',
|
||||
progress: taskResult.status === 'completed' ? 100 : 0,
|
||||
imageUrl: taskResult.image_path
|
||||
? `/api/v1/writer/images/${item.imageId}/file/`
|
||||
: (taskResult.image_url || item.imageUrl),
|
||||
imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing
|
||||
error: taskResult.error || null
|
||||
};
|
||||
}
|
||||
@@ -451,7 +574,13 @@ export default function ImageQueueModal({
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{item.contentTitle}
|
||||
{item.prompt
|
||||
? (() => {
|
||||
const words = item.prompt.split(' ');
|
||||
const firstTenWords = words.slice(0, 10).join(' ');
|
||||
return words.length > 10 ? `${firstTenWords}...` : item.prompt;
|
||||
})()
|
||||
: item.contentTitle}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap flex items-center gap-1">
|
||||
{getStatusIcon(item.status)}
|
||||
@@ -459,15 +588,15 @@ export default function ImageQueueModal({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{/* Progress Bar - Use smooth progress for visual display */}
|
||||
<div className="relative h-5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getProgressColor(item.status)} transition-all duration-300 ease-out`}
|
||||
style={{ width: `${item.progress}%` }}
|
||||
className={`h-full ${getProgressColor(item.status)} transition-all duration-150 ease-out`}
|
||||
style={{ width: `${smoothProgress[item.index] ?? item.progress ?? 0}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-gray-700 dark:text-gray-200">
|
||||
{item.progress}%
|
||||
{smoothProgress[item.index] ?? item.progress ?? 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,13 +609,22 @@ export default function ImageQueueModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Thumbnail */}
|
||||
{/* Right: Thumbnail - Only show if image is completed and saved */}
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.imageUrl ? (
|
||||
{item.status === 'completed' && item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.label}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load (not yet available)
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '<span class="text-xs text-gray-400 dark:text-gray-500">No image</span>';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
|
||||
@@ -229,6 +229,7 @@ export default function Images() {
|
||||
label: 'Featured Image',
|
||||
type: 'featured',
|
||||
contentTitle: contentImages.content_title || `Content #${contentId}`,
|
||||
prompt: contentImages.featured_image.prompt,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
imageUrl: null,
|
||||
@@ -250,6 +251,7 @@ export default function Images() {
|
||||
type: 'in_article',
|
||||
position: img.position || idx + 1,
|
||||
contentTitle: contentImages.content_title || `Content #${contentId}`,
|
||||
prompt: img.prompt,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
imageUrl: null,
|
||||
|
||||
Reference in New Issue
Block a user