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

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
};
}