Files
pnger/docs/LIVE_PREVIEW_IMPLEMENTATION.md

10 KiB

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:

// 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:

<!-- 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:

// 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

// 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

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

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
// 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

// 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