Add live preview implementation guide
This commit is contained in:
366
docs/LIVE_PREVIEW_IMPLEMENTATION.md
Normal file
366
docs/LIVE_PREVIEW_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
# Live Preview Implementation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Live Preview is the #1 priority feature for PNGer. This guide outlines the implementation approach.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Instant Feedback**: Show preview within 100ms of parameter change
|
||||||
|
2. **Accurate Rendering**: Match final output as closely as possible
|
||||||
|
3. **Performance**: Don't block UI, handle large images efficiently
|
||||||
|
4. **Progressive**: Show low-quality preview immediately, high-quality after
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Approach: Hybrid Client + Server Preview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Upload │
|
||||||
|
│ Image │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Client-Side Preview (Canvas) │ <-- Instant (< 100ms)
|
||||||
|
│ - Fast, approximate rendering │
|
||||||
|
│ - Uses browser native resize │
|
||||||
|
│ - Good for basic operations │
|
||||||
|
└─────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Server Preview API (Optional) │ <-- Accurate (500ms-2s)
|
||||||
|
│ - Uses Sharp (same as export) │
|
||||||
|
│ - Exact output representation │
|
||||||
|
│ - Debounced to avoid spam │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Phase 1: Client-Side Preview (Quick Win)
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `frontend/src/App.svelte`
|
||||||
|
- `frontend/src/lib/preview.ts` (new)
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
1. Canvas-based image rendering
|
||||||
|
2. Debounced updates (300ms after parameter change)
|
||||||
|
3. Show original and preview side-by-side
|
||||||
|
4. Display file size estimate
|
||||||
|
|
||||||
|
**Code Skeleton:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/lib/preview.ts
|
||||||
|
export async function generateClientPreview(
|
||||||
|
file: File,
|
||||||
|
options: TransformOptions
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate dimensions
|
||||||
|
const { width, height } = calculateDimensions(img, options);
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Apply transforms
|
||||||
|
if (options.fit === 'cover') {
|
||||||
|
drawCover(ctx, img, width, height, options.position);
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters (grayscale, blur, etc.)
|
||||||
|
applyFilters(ctx, options);
|
||||||
|
|
||||||
|
// Convert to data URL
|
||||||
|
const quality = options.quality / 100;
|
||||||
|
const dataUrl = canvas.toDataURL(`image/${options.format}`, quality);
|
||||||
|
resolve(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Updates:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- App.svelte additions -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { generateClientPreview } from './lib/preview';
|
||||||
|
import { debounce } from './lib/utils';
|
||||||
|
|
||||||
|
let previewUrl: string | null = null;
|
||||||
|
let originalSize: number = 0;
|
||||||
|
let previewSize: number = 0;
|
||||||
|
|
||||||
|
// Debounced preview generation
|
||||||
|
const updatePreview = debounce(async () => {
|
||||||
|
if (!file) return;
|
||||||
|
previewUrl = await generateClientPreview(file, {
|
||||||
|
width, height, quality, format, fit, position
|
||||||
|
});
|
||||||
|
// Calculate sizes
|
||||||
|
originalSize = file.size;
|
||||||
|
previewSize = estimateSize(previewUrl);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Call on any parameter change
|
||||||
|
$: if (file) updatePreview();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
{#if file && previewUrl}
|
||||||
|
<div class="preview-container">
|
||||||
|
<div class="image-comparison">
|
||||||
|
<div class="original">
|
||||||
|
<h3>Original</h3>
|
||||||
|
<img src={URL.createObjectURL(file)} alt="Original" />
|
||||||
|
<p>{formatFileSize(originalSize)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="preview">
|
||||||
|
<h3>Preview</h3>
|
||||||
|
<img src={previewUrl} alt="Preview" />
|
||||||
|
<p>{formatFileSize(previewSize)}</p>
|
||||||
|
<p class="savings">
|
||||||
|
{calculateSavings(originalSize, previewSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Server Preview API (Accurate)
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `backend/src/routes/image.ts`
|
||||||
|
|
||||||
|
**New Endpoint:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// POST /api/preview (returns base64 or temp URL)
|
||||||
|
router.post(
|
||||||
|
"/preview",
|
||||||
|
upload.single("file"),
|
||||||
|
async (req, res): Promise<void> => {
|
||||||
|
// Same processing as /transform
|
||||||
|
// But return as base64 data URL or temp storage URL
|
||||||
|
// Max preview size: 1200px (for performance)
|
||||||
|
|
||||||
|
const previewBuffer = await image.toBuffer();
|
||||||
|
const base64 = previewBuffer.toString('base64');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
preview: `data:image/${format};base64,${base64}`,
|
||||||
|
size: previewBuffer.length,
|
||||||
|
dimensions: { width: metadata.width, height: metadata.height }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Exact rendering (uses Sharp like final output)
|
||||||
|
- Shows actual file size
|
||||||
|
- Handles complex operations client can't do
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Slower (network round-trip)
|
||||||
|
- Server load (mitigate with rate limiting)
|
||||||
|
|
||||||
|
### Phase 3: Progressive Loading
|
||||||
|
|
||||||
|
**Enhancement**: Show low-quality preview first, then high-quality
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generate two previews:
|
||||||
|
// 1. Immediate low-res (client-side, 200px max)
|
||||||
|
// 2. Delayed high-res (server-side, full resolution)
|
||||||
|
|
||||||
|
async function generateProgressivePreview() {
|
||||||
|
// Step 1: Fast low-res
|
||||||
|
const lowRes = await generateClientPreview(file, {
|
||||||
|
...options,
|
||||||
|
width: Math.min(options.width || 200, 200),
|
||||||
|
height: Math.min(options.height || 200, 200)
|
||||||
|
});
|
||||||
|
previewUrl = lowRes; // Show immediately
|
||||||
|
|
||||||
|
// Step 2: High-res from server (debounced)
|
||||||
|
const highRes = await fetchServerPreview(file, options);
|
||||||
|
previewUrl = highRes; // Replace when ready
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Size Estimation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function estimateSize(dataUrl: string): number {
|
||||||
|
// Base64 data URL size (approximate)
|
||||||
|
const base64Length = dataUrl.split(',')[1].length;
|
||||||
|
return Math.ceil((base64Length * 3) / 4); // Convert base64 to bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSavings(original: number, preview: number): string {
|
||||||
|
const diff = original - preview;
|
||||||
|
const percent = ((diff / original) * 100).toFixed(1);
|
||||||
|
if (diff > 0) return `↓ ${formatFileSize(diff)} saved (${percent}%)`;
|
||||||
|
if (diff < 0) return `↑ ${formatFileSize(-diff)} larger (${Math.abs(Number(percent))}%)`;
|
||||||
|
return 'Same size';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI/UX Considerations
|
||||||
|
|
||||||
|
### Layout Options
|
||||||
|
|
||||||
|
**Option A: Side-by-Side**
|
||||||
|
```
|
||||||
|
┌──────────────┬──────────────┐
|
||||||
|
│ Original │ Preview │
|
||||||
|
│ │ │
|
||||||
|
│ [Image] │ [Image] │
|
||||||
|
│ 2.4 MB │ 450 KB │
|
||||||
|
│ 1920x1080 │ 800x600 │
|
||||||
|
└──────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Slider Compare**
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ [<──── Slider ────>] │
|
||||||
|
│ Original │ Preview │
|
||||||
|
│ │ │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C: Tabs**
|
||||||
|
```
|
||||||
|
┌─ Original ─┬─ Preview ─────┐
|
||||||
|
│ │
|
||||||
|
│ [Image] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Start with Option A (simplest), add Option B later for detail comparison.
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
### 1. Debouncing
|
||||||
|
```typescript
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Image Downsampling
|
||||||
|
- Preview max size: 1200px (retina displays)
|
||||||
|
- Original size only for final download
|
||||||
|
- Reduces memory usage and processing time
|
||||||
|
|
||||||
|
### 3. Worker Thread (Advanced)
|
||||||
|
- Offload canvas operations to Web Worker
|
||||||
|
- Keeps UI responsive during processing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// preview.worker.ts
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { file, options } = e.data;
|
||||||
|
const preview = await generatePreview(file, options);
|
||||||
|
self.postMessage({ preview });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- [ ] `calculateDimensions()` with various aspect ratios
|
||||||
|
- [ ] `formatFileSize()` edge cases
|
||||||
|
- [ ] `debounce()` timing
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- [ ] Preview updates on parameter change
|
||||||
|
- [ ] Preview matches final output (within tolerance)
|
||||||
|
- [ ] Large image handling (> 10MB)
|
||||||
|
- [ ] Multiple format conversions
|
||||||
|
|
||||||
|
### Manual Tests
|
||||||
|
- [ ] Mobile responsiveness
|
||||||
|
- [ ] Slow network simulation
|
||||||
|
- [ ] Various image formats (PNG, JPEG, WebP)
|
||||||
|
- [ ] Edge cases (1x1px, 10000x10000px)
|
||||||
|
|
||||||
|
## Rollout Strategy
|
||||||
|
|
||||||
|
### Step 1: Feature Flag
|
||||||
|
```typescript
|
||||||
|
// Enable via environment variable
|
||||||
|
const ENABLE_PREVIEW = import.meta.env.VITE_ENABLE_PREVIEW === 'true';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Beta Testing
|
||||||
|
- Deploy to staging environment
|
||||||
|
- Gather user feedback
|
||||||
|
- Monitor performance metrics
|
||||||
|
|
||||||
|
### Step 3: Gradual Rollout
|
||||||
|
- Enable for 10% of users
|
||||||
|
- Monitor error rates
|
||||||
|
- Full rollout if stable
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- **User Engagement**: Time spent on page increases
|
||||||
|
- **Conversion**: More downloads completed
|
||||||
|
- **Performance**: Preview renders in < 500ms (p95)
|
||||||
|
- **Accuracy**: Preview matches output 95%+ of time
|
||||||
|
- **Satisfaction**: User feedback positive
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Before/after slider with drag handle
|
||||||
|
- [ ] Zoom on preview (inspect details)
|
||||||
|
- [ ] Multiple preview sizes simultaneously
|
||||||
|
- [ ] A/B comparison (compare 2-4 settings)
|
||||||
|
- [ ] Preview history (undo/redo preview)
|
||||||
|
- [ ] Export preview settings as preset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Estimated Effort**: 2-3 days for Phase 1 (client preview)
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Impact**: ⭐⭐⭐⭐⭐ (Highest)
|
||||||
|
|
||||||
|
**Next Steps**:
|
||||||
|
1. Create feature branch `feature/live-preview`
|
||||||
|
2. Implement client-side preview
|
||||||
|
3. Add UI components
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Merge to main
|
||||||
|
6. Deploy and monitor
|
||||||
Reference in New Issue
Block a user