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