|
|
|
|
@@ -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">
|
|
|
|
|
|