Fix Image Processing
This commit is contained in:
220
ASYNC_IMAGE_PROCESSING.md
Normal file
220
ASYNC_IMAGE_PROCESSING.md
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
259
frontend/src/components/ImageProcessingProgress.tsx
Normal file
259
frontend/src/components/ImageProcessingProgress.tsx
Normal 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;
|
||||
246
frontend/src/utils/imageProcessingProgress.ts
Normal file
246
frontend/src/utils/imageProcessingProgress.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user