Fix Image Processing

This commit is contained in:
Stefan Hardegger
2025-09-28 20:06:52 +02:00
parent 622cf9ac76
commit c291559366
8 changed files with 1089 additions and 19 deletions

220
ASYNC_IMAGE_PROCESSING.md Normal file
View File

@@ -0,0 +1,220 @@
# Async Image Processing Implementation
## Overview
The image processing system has been updated to handle external images asynchronously, preventing timeouts when processing stories with many images. This provides real-time progress updates to users showing which images are being processed.
## Backend Components
### 1. `ImageProcessingProgressService`
- Tracks progress for individual story image processing sessions
- Thread-safe with `ConcurrentHashMap` for multi-user support
- Provides progress information: total images, processed count, current image, status, errors
### 2. `AsyncImageProcessingService`
- Handles asynchronous image processing using Spring's `@Async` annotation
- Counts external images before processing
- Provides progress callbacks during processing
- Updates story content when processing completes
- Automatic cleanup of progress data after completion
### 3. Enhanced `ImageService`
- Added `processContentImagesWithProgress()` method with callback support
- Progress callbacks provide real-time updates during image download/processing
- Maintains compatibility with existing synchronous processing
### 4. Updated `StoryController`
- `POST /api/stories` and `PUT /api/stories/{id}` now trigger async image processing
- `GET /api/stories/{id}/image-processing-progress` endpoint for progress polling
- Processing starts immediately after story save and returns control to user
## Frontend Components
### 1. `ImageProcessingProgressTracker` (Utility Class)
```typescript
const tracker = new ImageProcessingProgressTracker(storyId);
tracker.onProgress((progress) => {
console.log(`Processing ${progress.processedImages}/${progress.totalImages}`);
});
tracker.onComplete(() => console.log('Done!'));
tracker.start();
```
### 2. `ImageProcessingProgressComponent` (React Component)
```tsx
<ImageProcessingProgressComponent
storyId={storyId}
autoStart={true}
onComplete={() => refreshStory()}
/>
```
## User Experience
### Before (Synchronous)
1. User saves story with external images
2. Request hangs for 30+ seconds processing images
3. Browser may timeout
4. No feedback about progress
5. User doesn't know if it's working
### After (Asynchronous)
1. User saves story with external images
2. Save completes immediately
3. Progress indicator appears: "Processing 5 images. Currently image 2 of 5..."
4. User can continue using the application
5. Progress updates every second
6. Story automatically refreshes when processing completes
## API Endpoints
### Progress Endpoint
```
GET /api/stories/{id}/image-processing-progress
```
**Response when processing:**
```json
{
"isProcessing": true,
"totalImages": 5,
"processedImages": 2,
"currentImageUrl": "https://example.com/image.jpg",
"status": "Processing image 3 of 5",
"progressPercentage": 40.0,
"completed": false,
"error": ""
}
```
**Response when completed:**
```json
{
"isProcessing": false,
"totalImages": 5,
"processedImages": 5,
"currentImageUrl": "",
"status": "Completed: 5 images processed",
"progressPercentage": 100.0,
"completed": true,
"error": ""
}
```
**Response when no processing:**
```json
{
"isProcessing": false,
"message": "No active image processing"
}
```
## Integration Examples
### React Hook Usage
```tsx
import { useImageProcessingProgress } from '../utils/imageProcessingProgress';
function StoryEditor({ storyId }) {
const { progress, isTracking, startTracking } = useImageProcessingProgress(storyId);
const handleSave = async () => {
await saveStory();
startTracking(); // Start monitoring progress
};
return (
<div>
{isTracking && progress && (
<div className="progress-indicator">
Processing {progress.processedImages}/{progress.totalImages} images...
</div>
)}
<button onClick={handleSave}>Save Story</button>
</div>
);
}
```
### Manual Progress Tracking
```typescript
// After saving a story with external images
const tracker = new ImageProcessingProgressTracker(storyId);
tracker.onProgress((progress) => {
updateProgressBar(progress.progressPercentage);
showStatus(progress.status);
if (progress.currentImageUrl) {
showCurrentImage(progress.currentImageUrl);
}
});
tracker.onComplete((finalProgress) => {
hideProgressBar();
showNotification('Image processing completed!');
refreshStoryContent(); // Reload story with processed images
});
tracker.onError((error) => {
hideProgressBar();
showError(`Image processing failed: ${error}`);
});
tracker.start();
```
## Configuration
### Polling Interval
Default: 1 second (1000ms)
```typescript
const tracker = new ImageProcessingProgressTracker(storyId, 500); // Poll every 500ms
```
### Timeout
Default: 5 minutes (300000ms)
```typescript
const tracker = new ImageProcessingProgressTracker(storyId, 1000, 600000); // 10 minute timeout
```
### Spring Async Configuration
The backend uses Spring's default async executor. For production, consider configuring a custom thread pool in your application properties:
```yaml
spring:
task:
execution:
pool:
core-size: 4
max-size: 8
queue-capacity: 100
```
## Error Handling
### Backend Errors
- Network timeouts downloading images
- Invalid image formats
- Disk space issues
- All errors are logged and returned in progress status
### Frontend Errors
- Network failures during progress polling
- Timeout if processing takes too long
- Graceful degradation - user can continue working
## Benefits
1. **No More Timeouts**: Large image processing operations won't timeout HTTP requests
2. **Better UX**: Users get real-time feedback about processing progress
3. **Improved Performance**: Users can continue using the app while images process
4. **Error Visibility**: Clear error messages when image processing fails
5. **Scalability**: Multiple users can process images simultaneously without blocking
## Future Enhancements
1. **WebSocket Support**: Replace polling with WebSocket for real-time push updates
2. **Batch Processing**: Queue multiple stories for batch image processing
3. **Retry Logic**: Automatic retry for failed image downloads
4. **Progress Persistence**: Save progress to database for recovery after server restart
5. **Image Optimization**: Automatic resize/compress images during processing

View File

@@ -2,10 +2,12 @@ package com.storycove;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class StoryCoveApplication {
public static void main(String[] args) {

View File

@@ -44,6 +44,8 @@ public class StoryController {
private final ReadingTimeService readingTimeService;
private final EPUBImportService epubImportService;
private final EPUBExportService epubExportService;
private final AsyncImageProcessingService asyncImageProcessingService;
private final ImageProcessingProgressService progressService;
public StoryController(StoryService storyService,
AuthorService authorService,
@@ -54,7 +56,9 @@ public class StoryController {
SearchServiceAdapter searchServiceAdapter,
ReadingTimeService readingTimeService,
EPUBImportService epubImportService,
EPUBExportService epubExportService) {
EPUBExportService epubExportService,
AsyncImageProcessingService asyncImageProcessingService,
ImageProcessingProgressService progressService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
@@ -65,6 +69,8 @@ public class StoryController {
this.readingTimeService = readingTimeService;
this.epubImportService = epubImportService;
this.epubExportService = epubExportService;
this.asyncImageProcessingService = asyncImageProcessingService;
this.progressService = progressService;
}
@GetMapping
@@ -718,28 +724,15 @@ public class StoryController {
private Story processExternalImagesIfNeeded(Story story) {
try {
if (story.getContentHtml() != null && !story.getContentHtml().trim().isEmpty()) {
logger.debug("Processing external images for story: {}", story.getId());
logger.debug("Starting async image processing for story: {}", story.getId());
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(story.getContentHtml(), story.getId());
// Start async processing - this returns immediately
asyncImageProcessingService.processStoryImagesAsync(story.getId(), story.getContentHtml());
// If content was changed (external images were processed), update the story
if (!result.getProcessedContent().equals(story.getContentHtml())) {
logger.info("External images processed for story {}: {} images downloaded",
story.getId(), result.getDownloadedImages().size());
// Update the story with the processed content
story.setContentHtml(result.getProcessedContent());
story = storyService.updateContentOnly(story.getId(), result.getProcessedContent());
}
if (result.hasWarnings()) {
logger.warn("Image processing warnings for story {}: {}",
story.getId(), result.getWarnings());
}
logger.info("Async image processing started for story: {}", story.getId());
}
} catch (Exception e) {
logger.error("Failed to process external images for story {}: {}",
logger.error("Failed to start async image processing for story {}: {}",
story.getId(), e.getMessage(), e);
// Don't fail the entire operation if image processing fails
}
@@ -747,6 +740,31 @@ public class StoryController {
return story;
}
@GetMapping("/{id}/image-processing-progress")
public ResponseEntity<Map<String, Object>> getImageProcessingProgress(@PathVariable UUID id) {
ImageProcessingProgressService.ImageProcessingProgress progress = progressService.getProgress(id);
if (progress == null) {
return ResponseEntity.ok(Map.of(
"isProcessing", false,
"message", "No active image processing"
));
}
Map<String, Object> response = Map.of(
"isProcessing", !progress.isCompleted(),
"totalImages", progress.getTotalImages(),
"processedImages", progress.getProcessedImages(),
"currentImageUrl", progress.getCurrentImageUrl() != null ? progress.getCurrentImageUrl() : "",
"status", progress.getStatus(),
"progressPercentage", progress.getProgressPercentage(),
"completed", progress.isCompleted(),
"error", progress.getErrorMessage() != null ? progress.getErrorMessage() : ""
);
return ResponseEntity.ok(response);
}
@GetMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(
@RequestParam String title,

View File

@@ -0,0 +1,122 @@
package com.storycove.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class AsyncImageProcessingService {
private static final Logger logger = LoggerFactory.getLogger(AsyncImageProcessingService.class);
private final ImageService imageService;
private final StoryService storyService;
private final ImageProcessingProgressService progressService;
@Autowired
public AsyncImageProcessingService(ImageService imageService,
StoryService storyService,
ImageProcessingProgressService progressService) {
this.imageService = imageService;
this.storyService = storyService;
this.progressService = progressService;
}
@Async
public CompletableFuture<Void> processStoryImagesAsync(UUID storyId, String contentHtml) {
logger.info("Starting async image processing for story: {}", storyId);
try {
// Count external images first
int externalImageCount = countExternalImages(contentHtml);
if (externalImageCount == 0) {
logger.debug("No external images found for story {}", storyId);
return CompletableFuture.completedFuture(null);
}
// Start progress tracking
ImageProcessingProgressService.ImageProcessingProgress progress =
progressService.startProgress(storyId, externalImageCount);
// Process images with progress updates
ImageService.ContentImageProcessingResult result =
processImagesWithProgress(contentHtml, storyId, progress);
// Update story with processed content if changed
if (!result.getProcessedContent().equals(contentHtml)) {
progressService.updateProgress(storyId, progress.getTotalImages(),
"Saving processed content", "Updating story content");
storyService.updateContentOnly(storyId, result.getProcessedContent());
progressService.completeProgress(storyId,
String.format("Completed: %d images processed", result.getDownloadedImages().size()));
logger.info("Async image processing completed for story {}: {} images processed",
storyId, result.getDownloadedImages().size());
} else {
progressService.completeProgress(storyId, "Completed: No images needed processing");
}
// Clean up progress after a delay to allow frontend to see completion
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(5000); // 5 seconds delay
progressService.removeProgress(storyId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (Exception e) {
logger.error("Async image processing failed for story {}: {}", storyId, e.getMessage(), e);
progressService.setError(storyId, e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
private int countExternalImages(String contentHtml) {
if (contentHtml == null || contentHtml.trim().isEmpty()) {
return 0;
}
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
Matcher matcher = imgPattern.matcher(contentHtml);
int count = 0;
while (matcher.find()) {
String src = matcher.group(1);
if (isExternalUrl(src)) {
count++;
}
}
return count;
}
private boolean isExternalUrl(String url) {
return url != null &&
(url.startsWith("http://") || url.startsWith("https://")) &&
!url.contains("/api/files/images/");
}
private ImageService.ContentImageProcessingResult processImagesWithProgress(
String contentHtml, UUID storyId, ImageProcessingProgressService.ImageProcessingProgress progress) {
// Use a custom version of processContentImages that provides progress callbacks
return imageService.processContentImagesWithProgress(contentHtml, storyId,
(currentUrl, processedCount, totalCount) -> {
progressService.updateProgress(storyId, processedCount, currentUrl,
String.format("Processing image %d of %d", processedCount + 1, totalCount));
});
}
}

View File

@@ -0,0 +1,108 @@
package com.storycove.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ImageProcessingProgressService {
private static final Logger logger = LoggerFactory.getLogger(ImageProcessingProgressService.class);
private final Map<UUID, ImageProcessingProgress> progressMap = new ConcurrentHashMap<>();
public static class ImageProcessingProgress {
private final UUID storyId;
private final int totalImages;
private volatile int processedImages;
private volatile String currentImageUrl;
private volatile String status;
private volatile boolean completed;
private volatile String errorMessage;
public ImageProcessingProgress(UUID storyId, int totalImages) {
this.storyId = storyId;
this.totalImages = totalImages;
this.processedImages = 0;
this.status = "Starting";
this.completed = false;
}
// Getters
public UUID getStoryId() { return storyId; }
public int getTotalImages() { return totalImages; }
public int getProcessedImages() { return processedImages; }
public String getCurrentImageUrl() { return currentImageUrl; }
public String getStatus() { return status; }
public boolean isCompleted() { return completed; }
public String getErrorMessage() { return errorMessage; }
public double getProgressPercentage() {
return totalImages > 0 ? (double) processedImages / totalImages * 100 : 100;
}
// Setters
public void setProcessedImages(int processedImages) { this.processedImages = processedImages; }
public void setCurrentImageUrl(String currentImageUrl) { this.currentImageUrl = currentImageUrl; }
public void setStatus(String status) { this.status = status; }
public void setCompleted(boolean completed) { this.completed = completed; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public void incrementProcessed() {
this.processedImages++;
}
}
public ImageProcessingProgress startProgress(UUID storyId, int totalImages) {
ImageProcessingProgress progress = new ImageProcessingProgress(storyId, totalImages);
progressMap.put(storyId, progress);
logger.info("Started image processing progress tracking for story {} with {} images", storyId, totalImages);
return progress;
}
public ImageProcessingProgress getProgress(UUID storyId) {
return progressMap.get(storyId);
}
public void updateProgress(UUID storyId, int processedImages, String currentImageUrl, String status) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setProcessedImages(processedImages);
progress.setCurrentImageUrl(currentImageUrl);
progress.setStatus(status);
logger.debug("Updated progress for story {}: {}/{} - {}", storyId, processedImages, progress.getTotalImages(), status);
}
}
public void completeProgress(UUID storyId, String finalStatus) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setCompleted(true);
progress.setStatus(finalStatus);
logger.info("Completed image processing for story {}: {}", storyId, finalStatus);
}
}
public void setError(UUID storyId, String errorMessage) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setErrorMessage(errorMessage);
progress.setStatus("Error: " + errorMessage);
progress.setCompleted(true);
logger.error("Image processing error for story {}: {}", storyId, errorMessage);
}
}
public void removeProgress(UUID storyId) {
progressMap.remove(storyId);
logger.debug("Removed progress tracking for story {}", storyId);
}
public boolean isProcessing(UUID storyId) {
ImageProcessingProgress progress = progressMap.get(storyId);
return progress != null && !progress.isCompleted();
}
}

View File

@@ -334,6 +334,101 @@ public class ImageService {
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Functional interface for progress callbacks during image processing
*/
@FunctionalInterface
public interface ImageProcessingProgressCallback {
void onProgress(String currentImageUrl, int processedCount, int totalCount);
}
/**
* Process content images with progress callbacks for async processing
*/
public ContentImageProcessingResult processContentImagesWithProgress(String htmlContent, UUID storyId, ImageProcessingProgressCallback progressCallback) {
logger.debug("Processing content images with progress for story: {}, content length: {}", storyId,
htmlContent != null ? htmlContent.length() : 0);
List<String> warnings = new ArrayList<>();
List<String> downloadedImages = new ArrayList<>();
if (htmlContent == null || htmlContent.trim().isEmpty()) {
logger.debug("No content to process for story: {}", storyId);
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
}
// Find all img tags with src attributes
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
Matcher matcher = imgPattern.matcher(htmlContent);
// First pass: count external images
List<String> externalImages = new ArrayList<>();
Matcher countMatcher = imgPattern.matcher(htmlContent);
while (countMatcher.find()) {
String imageUrl = countMatcher.group(1);
if (!imageUrl.startsWith("/") && !imageUrl.startsWith("data:")) {
externalImages.add(imageUrl);
}
}
int totalExternalImages = externalImages.size();
int processedCount = 0;
StringBuffer processedContent = new StringBuffer();
matcher.reset(); // Reset the matcher for processing
while (matcher.find()) {
String fullImgTag = matcher.group(0);
String imageUrl = matcher.group(1);
logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag);
try {
// Skip if it's already a local path or data URL
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
logger.debug("Skipping local/data URL: {}", imageUrl);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
continue;
}
// Call progress callback
if (progressCallback != null) {
progressCallback.onProgress(imageUrl, processedCount, totalExternalImages);
}
logger.debug("Processing external image #{}: {}", processedCount + 1, imageUrl);
// Download and store the image
String localPath = downloadImageFromUrl(imageUrl, storyId);
downloadedImages.add(localPath);
// Generate local URL
String localUrl = getLocalImageUrl(storyId, localPath);
logger.debug("Downloaded image: {} -> {}", imageUrl, localUrl);
// Replace the src attribute with the local path
String newImgTag = fullImgTag
.replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"")
.replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src='" + localUrl + "'");
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
processedCount++;
} catch (Exception e) {
logger.warn("Failed to download image: {} - Error: {}", imageUrl, e.getMessage());
warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage());
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
}
}
matcher.appendTail(processedContent);
logger.info("Processed {} external images for story: {} (Total: {}, Downloaded: {}, Warnings: {})",
processedCount, storyId, processedCount, downloadedImages.size(), warnings.size());
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Download an image from a URL and store it locally
*/

View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect } from 'react';
import { ImageProcessingProgressTracker, ImageProcessingProgress } from '../utils/imageProcessingProgress';
interface ImageProcessingProgressProps {
storyId: string;
autoStart?: boolean;
onComplete?: () => void;
onError?: (error: string) => void;
}
export const ImageProcessingProgressComponent: React.FC<ImageProcessingProgressProps> = ({
storyId,
autoStart = false,
onComplete,
onError
}) => {
const [progress, setProgress] = useState<ImageProcessingProgress | null>(null);
const [isTracking, setIsTracking] = useState(false);
const [tracker, setTracker] = useState<ImageProcessingProgressTracker | null>(null);
const startTracking = () => {
if (tracker) {
tracker.stop();
}
const newTracker = new ImageProcessingProgressTracker(storyId);
newTracker.onProgress((progress) => {
setProgress(progress);
});
newTracker.onComplete((finalProgress) => {
setProgress(finalProgress);
setIsTracking(false);
onComplete?.();
});
newTracker.onError((error) => {
console.error('Image processing error:', error);
setIsTracking(false);
onError?.(error);
});
setTracker(newTracker);
setIsTracking(true);
newTracker.start();
};
const stopTracking = () => {
if (tracker) {
tracker.stop();
setIsTracking(false);
}
};
useEffect(() => {
if (autoStart) {
startTracking();
}
return () => {
if (tracker) {
tracker.stop();
}
};
}, [storyId, autoStart]);
if (!progress && !isTracking) {
return null;
}
if (!progress?.isProcessing && !progress?.completed) {
return null;
}
return (
<div className="image-processing-progress">
<div className="progress-header">
<h4>Processing Images</h4>
{isTracking && (
<button onClick={stopTracking} className="btn btn-sm btn-secondary">
Cancel
</button>
)}
</div>
{progress && (
<div className="progress-content">
{progress.error ? (
<div className="alert alert-danger">
<strong>Error:</strong> {progress.error}
</div>
) : progress.completed ? (
<div className="alert alert-success">
<strong>Completed:</strong> {progress.status}
</div>
) : (
<div className="progress-info">
<div className="status-text">
<strong>Status:</strong> {progress.status}
</div>
<div className="progress-stats">
Processing {progress.processedImages} of {progress.totalImages} images
({progress.progressPercentage.toFixed(1)}%)
</div>
{progress.currentImageUrl && (
<div className="current-image">
<strong>Current:</strong>
<span className="image-url" title={progress.currentImageUrl}>
{progress.currentImageUrl.length > 60
? `...${progress.currentImageUrl.slice(-60)}`
: progress.currentImageUrl
}
</span>
</div>
)}
{/* Progress bar */}
<div className="progress-bar-container">
<div className="progress-bar">
<div
className="progress-bar-fill"
style={{ width: `${progress.progressPercentage}%` }}
/>
</div>
<span className="progress-percentage">
{progress.progressPercentage.toFixed(1)}%
</span>
</div>
</div>
)}
</div>
)}
<style jsx>{`
.image-processing-progress {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-header h4 {
margin: 0;
font-size: 1.1rem;
color: #495057;
}
.progress-content {
space-y: 0.5rem;
}
.status-text {
font-size: 0.9rem;
color: #6c757d;
margin-bottom: 0.5rem;
}
.progress-stats {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
.current-image {
font-size: 0.85rem;
color: #6c757d;
margin-bottom: 0.75rem;
}
.image-url {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #e9ecef;
padding: 0.1rem 0.3rem;
border-radius: 2px;
margin-left: 0.5rem;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
transition: width 0.3s ease;
}
.progress-percentage {
font-size: 0.85rem;
font-weight: 500;
color: #495057;
min-width: 3rem;
text-align: right;
}
.btn {
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.8rem;
}
.btn-secondary {
background: #6c757d;
color: white;
border-color: #6c757d;
}
.btn-secondary:hover {
background: #5a6268;
border-color: #545b62;
}
.alert {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.alert-danger {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
`}</style>
</div>
);
};
export default ImageProcessingProgressComponent;

View File

@@ -0,0 +1,246 @@
/**
* Utility for tracking image processing progress
*
* Usage example:
*
* // After saving a story, start polling for progress
* const progressTracker = new ImageProcessingProgressTracker(storyId);
*
* progressTracker.onProgress((progress) => {
* console.log(`Processing ${progress.processedImages}/${progress.totalImages} images`);
* console.log(`Current: ${progress.currentImageUrl}`);
* console.log(`Status: ${progress.status}`);
* });
*
* progressTracker.onComplete((finalProgress) => {
* console.log('Image processing completed!');
* });
*
* progressTracker.onError((error) => {
* console.error('Image processing failed:', error);
* });
*
* progressTracker.start();
*/
export interface ImageProcessingProgress {
isProcessing: boolean;
totalImages: number;
processedImages: number;
currentImageUrl: string;
status: string;
progressPercentage: number;
completed: boolean;
error: string;
message?: string;
}
export type ProgressCallback = (progress: ImageProcessingProgress) => void;
export type CompleteCallback = (finalProgress: ImageProcessingProgress) => void;
export type ErrorCallback = (error: string) => void;
export class ImageProcessingProgressTracker {
private storyId: string;
private pollInterval: number;
private timeoutMs: number;
private isPolling: boolean = false;
private pollTimer: NodeJS.Timeout | null = null;
private startTime: number = 0;
private progressCallbacks: ProgressCallback[] = [];
private completeCallbacks: CompleteCallback[] = [];
private errorCallbacks: ErrorCallback[] = [];
constructor(
storyId: string,
pollInterval: number = 1000, // Poll every 1 second
timeoutMs: number = 300000 // 5 minute timeout
) {
this.storyId = storyId;
this.pollInterval = pollInterval;
this.timeoutMs = timeoutMs;
}
public onProgress(callback: ProgressCallback): void {
this.progressCallbacks.push(callback);
}
public onComplete(callback: CompleteCallback): void {
this.completeCallbacks.push(callback);
}
public onError(callback: ErrorCallback): void {
this.errorCallbacks.push(callback);
}
public async start(): Promise<void> {
if (this.isPolling) {
console.warn('Progress tracking already started');
return;
}
this.isPolling = true;
this.startTime = Date.now();
console.log(`Starting image processing progress tracking for story ${this.storyId}`);
this.poll();
}
public stop(): void {
this.isPolling = false;
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
console.log(`Stopped progress tracking for story ${this.storyId}`);
}
private async poll(): Promise<void> {
if (!this.isPolling) {
return;
}
// Check for timeout
const elapsed = Date.now() - this.startTime;
if (elapsed > this.timeoutMs) {
this.handleError('Image processing timed out');
return;
}
try {
const response = await fetch(`/api/stories/${this.storyId}/image-processing-progress`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const progress: ImageProcessingProgress = await response.json();
// Call progress callbacks
this.progressCallbacks.forEach(callback => {
try {
callback(progress);
} catch (error) {
console.error('Error in progress callback:', error);
}
});
// Check if processing is complete
if (progress.completed) {
this.handleComplete(progress);
return;
}
// Check for errors
if (progress.error) {
this.handleError(progress.error);
return;
}
// Continue polling if still processing
if (progress.isProcessing) {
this.pollTimer = setTimeout(() => this.poll(), this.pollInterval);
} else {
// No active processing - might have finished or never started
this.handleComplete(progress);
}
} catch (error) {
this.handleError(`Failed to fetch progress: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handleComplete(finalProgress: ImageProcessingProgress): void {
this.stop();
console.log(`Image processing completed for story ${this.storyId}`);
this.completeCallbacks.forEach(callback => {
try {
callback(finalProgress);
} catch (error) {
console.error('Error in complete callback:', error);
}
});
}
private handleError(error: string): void {
this.stop();
console.error(`Image processing error for story ${this.storyId}:`, error);
this.errorCallbacks.forEach(callback => {
try {
callback(error);
} catch (error) {
console.error('Error in error callback:', error);
}
});
}
}
/**
* React hook for image processing progress
*
* Note: This hook requires React to be imported in the file where it's used.
* To use this hook, import React in your component file:
*
* import React from 'react';
* import { useImageProcessingProgress } from '../utils/imageProcessingProgress';
*
* Usage:
* const { progress, isTracking, startTracking } = useImageProcessingProgress(storyId);
*/
import React from 'react';
export function useImageProcessingProgress(storyId: string) {
const [progress, setProgress] = React.useState<ImageProcessingProgress | null>(null);
const [isTracking, setIsTracking] = React.useState(false);
const [tracker, setTracker] = React.useState<ImageProcessingProgressTracker | null>(null);
const startTracking = React.useCallback(() => {
if (tracker) {
tracker.stop();
}
const newTracker = new ImageProcessingProgressTracker(storyId);
newTracker.onProgress((progress) => {
setProgress(progress);
});
newTracker.onComplete((finalProgress) => {
setProgress(finalProgress);
setIsTracking(false);
});
newTracker.onError((error) => {
console.error('Image processing error:', error);
setIsTracking(false);
});
setTracker(newTracker);
setIsTracking(true);
newTracker.start();
}, [storyId, tracker]);
const stopTracking = React.useCallback(() => {
if (tracker) {
tracker.stop();
setIsTracking(false);
}
}, [tracker]);
React.useEffect(() => {
return () => {
if (tracker) {
tracker.stop();
}
};
}, [tracker]);
return {
progress,
isTracking,
startTracking,
stopTracking
};
}