diff --git a/ASYNC_IMAGE_PROCESSING.md b/ASYNC_IMAGE_PROCESSING.md new file mode 100644 index 0000000..783ee58 --- /dev/null +++ b/ASYNC_IMAGE_PROCESSING.md @@ -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 + 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 ( +
+ {isTracking && progress && ( +
+ Processing {progress.processedImages}/{progress.totalImages} images... +
+ )} + +
+ ); +} +``` + +### 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 \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/StoryCoveApplication.java b/backend/src/main/java/com/storycove/StoryCoveApplication.java index c441c93..d991e5e 100644 --- a/backend/src/main/java/com/storycove/StoryCoveApplication.java +++ b/backend/src/main/java/com/storycove/StoryCoveApplication.java @@ -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) { diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 84bdf54..b3905d4 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -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> 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 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> checkDuplicate( @RequestParam String title, diff --git a/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java b/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java new file mode 100644 index 0000000..a9188b4 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java @@ -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 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("]+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)); + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/ImageProcessingProgressService.java b/backend/src/main/java/com/storycove/service/ImageProcessingProgressService.java new file mode 100644 index 0000000..d861c9a --- /dev/null +++ b/backend/src/main/java/com/storycove/service/ImageProcessingProgressService.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/ImageService.java b/backend/src/main/java/com/storycove/service/ImageService.java index d98bb75..a662327 100644 --- a/backend/src/main/java/com/storycove/service/ImageService.java +++ b/backend/src/main/java/com/storycove/service/ImageService.java @@ -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 warnings = new ArrayList<>(); + List 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("]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE); + Matcher matcher = imgPattern.matcher(htmlContent); + + // First pass: count external images + List 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 */ diff --git a/frontend/src/components/ImageProcessingProgress.tsx b/frontend/src/components/ImageProcessingProgress.tsx new file mode 100644 index 0000000..e2dbc63 --- /dev/null +++ b/frontend/src/components/ImageProcessingProgress.tsx @@ -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 = ({ + storyId, + autoStart = false, + onComplete, + onError +}) => { + const [progress, setProgress] = useState(null); + const [isTracking, setIsTracking] = useState(false); + const [tracker, setTracker] = useState(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 ( +
+
+

Processing Images

+ {isTracking && ( + + )} +
+ + {progress && ( +
+ {progress.error ? ( +
+ Error: {progress.error} +
+ ) : progress.completed ? ( +
+ Completed: {progress.status} +
+ ) : ( +
+
+ Status: {progress.status} +
+ +
+ Processing {progress.processedImages} of {progress.totalImages} images + ({progress.progressPercentage.toFixed(1)}%) +
+ + {progress.currentImageUrl && ( +
+ Current: + + {progress.currentImageUrl.length > 60 + ? `...${progress.currentImageUrl.slice(-60)}` + : progress.currentImageUrl + } + +
+ )} + + {/* Progress bar */} +
+
+
+
+ + {progress.progressPercentage.toFixed(1)}% + +
+
+ )} +
+ )} + + +
+ ); +}; + +export default ImageProcessingProgressComponent; \ No newline at end of file diff --git a/frontend/src/utils/imageProcessingProgress.ts b/frontend/src/utils/imageProcessingProgress.ts new file mode 100644 index 0000000..fd5aeaa --- /dev/null +++ b/frontend/src/utils/imageProcessingProgress.ts @@ -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 { + 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 { + 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(null); + const [isTracking, setIsTracking] = React.useState(false); + const [tracker, setTracker] = React.useState(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 + }; +} \ No newline at end of file