Files
taskplaner/.planning/phases/03-images/03-RESEARCH.md
Thomas Richter d99bd0d3a2 docs(03): research phase domain
Phase 03: Images
- Standard stack identified (Sharp, svelte-lightbox, native browser APIs)
- Architecture patterns documented (form actions, camera capture, API endpoints)
- Pitfalls catalogued (EXIF rotation, camera cleanup, memory leaks)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:10:20 +01:00

19 KiB

Phase 3: Images - Research

Researched: 2026-01-29 Domain: Image attachments with file upload, camera capture, storage, and display Confidence: HIGH

Summary

This phase implements image attachments for entries with support for file upload (desktop), camera capture (mobile), thumbnail generation, and lightbox viewing. The research covers the complete image lifecycle: capture/upload on the client, transfer to server via form actions, storage on filesystem with thumbnail generation, and retrieval via API endpoints.

The established approach for SvelteKit image handling is:

  1. Client: HTML5 File API for uploads, MediaDevices API for camera capture, canvas.toBlob() for photo capture
  2. Server: Form actions with multipart/form-data, write files via Node.js fs/promises
  3. Processing: Sharp for thumbnail generation (dramatically faster than alternatives)
  4. Storage: Filesystem-based with API endpoints serving images (not static folder)
  5. Display: svelte-lightbox for fullscreen viewing, native img tags for thumbnails

Primary recommendation: Use Sharp for server-side thumbnail generation, native browser APIs for camera/upload, and svelte-lightbox for the lightbox component. Store images outside static/ and serve via +server.ts endpoints for better control.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
sharp latest Server-side image processing, thumbnail generation 4-5x faster than ImageMagick, 20x faster than Jimp. Native C++ bindings via libvips
svelte-lightbox 1.1.x Fullscreen image lightbox Purpose-built for Svelte/SvelteKit, keyboard navigation, mobile support

Supporting

Library Version Purpose When to Use
nanoid 5.1.x Generate unique filenames Already in project, use for image IDs
Node.js fs/promises built-in File system operations Write uploaded images to disk

Alternatives Considered

Instead of Could Use Tradeoff
sharp jimp Pure JS (no native deps), but 20x slower - not recommended
svelte-lightbox bigger-picture Smaller bundle, but needs Svelte 4 compatibility config for Svelte 5
Filesystem storage SQLite blob Simpler queries, but larger DB, slower, harder to backup

Installation:

npm install sharp svelte-lightbox

Architecture Patterns

data/
  uploads/
    originals/         # Full-size images (user uploaded)
    thumbnails/        # Generated thumbnails (150x150)
src/
  lib/
    server/
      images/
        storage.ts     # File system operations
        thumbnails.ts  # Sharp thumbnail generation
    components/
      ImageUpload.svelte      # Upload button + drag-drop zone
      CameraCapture.svelte    # Camera modal with preview
      ImageGallery.svelte     # Horizontal scroll gallery
      ImageLightbox.svelte    # Fullscreen viewer wrapper
  routes/
    api/
      images/
        [id]/
          +server.ts          # Serve original image
          thumbnail/
            +server.ts        # Serve thumbnail

Pattern 1: Form Action File Upload

What: Handle multipart form data in SvelteKit form actions When to use: File uploads from forms Example:

// +page.server.ts
import { writeFile } from 'node:fs/promises';
import { nanoid } from 'nanoid';
import sharp from 'sharp';

export const actions = {
  uploadImage: async ({ request }) => {
    const formData = await request.formData();
    const file = formData.get('image') as File;
    const entryId = formData.get('entryId') as string;

    if (!file || file.size === 0) {
      return fail(400, { error: 'No file uploaded' });
    }

    const imageId = nanoid();
    const ext = file.name.split('.').pop() || 'jpg';
    const buffer = Buffer.from(await file.arrayBuffer());

    // Save original
    await writeFile(`data/uploads/originals/${imageId}.${ext}`, buffer);

    // Generate thumbnail with EXIF auto-rotation
    await sharp(buffer)
      .rotate()  // Auto-orient based on EXIF
      .resize(150, 150, { fit: 'cover', position: 'center' })
      .jpeg({ quality: 80 })
      .toFile(`data/uploads/thumbnails/${imageId}.jpg`);

    // Save to database
    // imageRepository.create({ id: imageId, entryId, ext });

    return { success: true, imageId };
  }
};

Pattern 2: Camera Capture with getUserMedia

What: Access device camera directly in browser When to use: Mobile photo capture, desktop webcam Example:

// CameraCapture.svelte
<script lang="ts">
  let videoElement: HTMLVideoElement;
  let canvasElement: HTMLCanvasElement;
  let stream: MediaStream | null = null;
  let capturedBlob: Blob | null = null;

  async function startCamera(facingMode: 'user' | 'environment' = 'environment') {
    // Stop existing stream first
    if (stream) {
      stream.getTracks().forEach(track => track.stop());
    }

    stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode },
      audio: false
    });
    videoElement.srcObject = stream;
    await videoElement.play();
  }

  async function capturePhoto(): Promise<Blob> {
    const ctx = canvasElement.getContext('2d')!;
    canvasElement.width = videoElement.videoWidth;
    canvasElement.height = videoElement.videoHeight;
    ctx.drawImage(videoElement, 0, 0);

    return new Promise(resolve => {
      canvasElement.toBlob(blob => resolve(blob!), 'image/jpeg', 0.9);
    });
  }

  async function handleCapture() {
    capturedBlob = await capturePhoto();
    // Show preview, allow retake or confirm
  }

  async function confirmAndUpload() {
    const formData = new FormData();
    formData.append('image', capturedBlob!, 'camera-capture.jpg');
    formData.append('entryId', entryId);

    await fetch('?/uploadImage', {
      method: 'POST',
      body: formData
    });
  }

  function stopCamera() {
    stream?.getTracks().forEach(track => track.stop());
    stream = null;
  }
</script>

Pattern 3: API Endpoint for Serving Images

What: Serve uploaded images via +server.ts endpoints When to use: Serving user-uploaded files not in static/ Example:

// src/routes/api/images/[id]/+server.ts
import { readFile } from 'node:fs/promises';
import { error } from '@sveltejs/kit';

export async function GET({ params }) {
  const { id } = params;

  // Look up image metadata from database
  const image = imageRepository.getById(id);
  if (!image) {
    throw error(404, 'Image not found');
  }

  const filePath = `data/uploads/originals/${id}.${image.ext}`;

  try {
    const data = await readFile(filePath);
    return new Response(data, {
      headers: {
        'Content-Type': `image/${image.ext === 'jpg' ? 'jpeg' : image.ext}`,
        'Content-Length': data.length.toString(),
        'Cache-Control': 'public, max-age=31536000, immutable'
      }
    });
  } catch {
    throw error(404, 'Image file not found');
  }
}

Pattern 4: Drag and Drop Zone

What: HTML5 drag-and-drop file upload When to use: Desktop file upload experience Example:

// ImageUpload.svelte
<script lang="ts">
  let isDragging = $state(false);
  let fileInput: HTMLInputElement;

  function handleDragOver(e: DragEvent) {
    e.preventDefault();
    isDragging = true;
  }

  function handleDragLeave(e: DragEvent) {
    e.preventDefault();
    isDragging = false;
  }

  function handleDrop(e: DragEvent) {
    e.preventDefault();
    isDragging = false;

    const file = e.dataTransfer?.files[0];
    if (file && file.type.startsWith('image/')) {
      uploadFile(file);
    }
  }

  function handleFileSelect(e: Event) {
    const input = e.target as HTMLInputElement;
    const file = input.files?.[0];
    if (file) {
      uploadFile(file);
    }
  }

  async function uploadFile(file: File) {
    // Show optimistic preview immediately
    const previewUrl = URL.createObjectURL(file);
    // dispatch('preview', { url: previewUrl });

    // Upload in background
    const formData = new FormData();
    formData.append('image', file);
    formData.append('entryId', entryId);

    const response = await fetch('?/uploadImage', {
      method: 'POST',
      body: formData
    });

    // Handle response, revoke preview URL
    URL.revokeObjectURL(previewUrl);
  }
</script>

<div
  class="drop-zone {isDragging ? 'dragging' : ''}"
  ondragover={handleDragOver}
  ondragleave={handleDragLeave}
  ondrop={handleDrop}
  role="button"
  tabindex="0"
  onclick={() => fileInput.click()}
  onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
>
  <input
    bind:this={fileInput}
    type="file"
    accept="image/*"
    onchange={handleFileSelect}
    class="hidden"
  />
  Drop image here or click to upload
</div>

Anti-Patterns to Avoid

  • Storing images in SQLite: Bloats database, slows queries, complicates backups
  • Storing in static/ folder: Files overwritten on deploy, not recommended for user uploads
  • Blocking UI during upload: Always show optimistic preview, upload in background
  • Missing EXIF rotation: Images from phones will appear rotated; always use sharp.rotate()
  • Using jimp for thumbnails: 20x slower than Sharp, poor choice for any volume

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Thumbnail generation Canvas resize on client Sharp on server Server handles EXIF rotation, consistent quality, proper color management
Image EXIF orientation Manual rotation logic sharp.rotate() Complex orientation matrix, mirroring cases, Sharp handles all 8 orientations
Lightbox gallery Custom modal + gestures svelte-lightbox Keyboard navigation, pinch-zoom, swipe, accessibility baked in
Camera permissions Custom prompts getUserMedia native prompts Browser handles permission UI, remembers choice, shows camera indicator
File type validation Regex on extension File API + accept attribute MIME type checking, browser validates before upload

Key insight: Image handling has many edge cases (EXIF, color profiles, format variations). Sharp and browser APIs handle these; custom solutions will miss them.

Common Pitfalls

Pitfall 1: Missing EXIF Auto-Rotation

What goes wrong: Photos from mobile devices appear rotated 90 or 180 degrees Why it happens: Mobile cameras embed rotation in EXIF metadata rather than rotating pixels How to avoid: Always call sharp.rotate() without arguments before any resize operation Warning signs: Portrait photos appearing landscape, images "sideways"

Pitfall 2: Blocking UI During Upload

What goes wrong: User clicks upload, UI freezes until complete Why it happens: Not showing optimistic preview, waiting for server response How to avoid: Use URL.createObjectURL() for instant preview, upload via background fetch Warning signs: "Nothing happened" user feedback, perceived slowness

Pitfall 3: Camera Not Available Handling

What goes wrong: App crashes or shows blank when camera unavailable Why it happens: Not catching getUserMedia errors, not checking navigator.mediaDevices How to avoid: Feature detection first, catch NotAllowedError and NotFoundError, show graceful fallback Warning signs: White screen on desktop without webcam, permission denied with no feedback

Pitfall 4: Memory Leaks from Object URLs

What goes wrong: Memory usage grows over time as user uploads images Why it happens: Not calling URL.revokeObjectURL() after preview no longer needed How to avoid: Revoke URLs when component unmounts or when upload completes Warning signs: Browser tab memory increasing, eventual slowdown

Pitfall 5: Form Without enctype

What goes wrong: File upload appears to work but server receives empty file Why it happens: Missing enctype="multipart/form-data" on form element How to avoid: Always set enctype for file upload forms, or use FormData with fetch directly Warning signs: formData.get('file') returns null or 0-byte file

Pitfall 6: Camera Stream Not Stopped

What goes wrong: Camera indicator stays on, battery drain, privacy concern Why it happens: Not calling track.stop() when closing camera modal How to avoid: Always stop all tracks when camera not in use: stream.getTracks().forEach(t => t.stop()) Warning signs: Browser camera indicator remains active after closing

Code Examples

Verified patterns from official sources:

Sharp Thumbnail Generation

// Source: https://sharp.pixelplumbing.com/api-resize
import sharp from 'sharp';

async function generateThumbnail(
  inputBuffer: Buffer,
  outputPath: string,
  size = 150
): Promise<void> {
  await sharp(inputBuffer)
    .rotate()  // Auto-orient based on EXIF Orientation tag
    .resize(size, size, {
      fit: 'cover',           // Crop to fill dimensions
      position: 'center',     // Center the crop
      withoutEnlargement: true // Don't upscale small images
    })
    .jpeg({ quality: 80 })
    .toFile(outputPath);
}

getUserMedia Camera Access

// Source: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
async function startCamera(preferBack = true): Promise<MediaStream> {
  // Feature detection
  if (!navigator.mediaDevices?.getUserMedia) {
    throw new Error('Camera not supported in this browser');
  }

  try {
    return await navigator.mediaDevices.getUserMedia({
      video: {
        facingMode: preferBack ? 'environment' : 'user',
        width: { ideal: 1920 },
        height: { ideal: 1080 }
      },
      audio: false
    });
  } catch (err) {
    if (err instanceof DOMException) {
      if (err.name === 'NotAllowedError') {
        throw new Error('Camera permission denied');
      }
      if (err.name === 'NotFoundError') {
        throw new Error('No camera found');
      }
    }
    throw err;
  }
}

Canvas to Blob for Photo Capture

// Source: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
function canvasToBlob(
  canvas: HTMLCanvasElement,
  type = 'image/jpeg',
  quality = 0.9
): Promise<Blob> {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => {
        if (blob) resolve(blob);
        else reject(new Error('Failed to create blob'));
      },
      type,
      quality
    );
  });
}

SvelteKit Form Action File Upload

// Source: https://travishorn.com/uploading-and-saving-files-with-sveltekit
import { writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';

export const actions = {
  upload: async ({ request }) => {
    const formData = await request.formData();
    const file = formData.get('file') as File;

    if (!file || file.size === 0) {
      return fail(400, { error: 'No file provided' });
    }

    // Ensure upload directory exists
    const uploadDir = 'data/uploads/originals';
    if (!existsSync(uploadDir)) {
      await mkdir(uploadDir, { recursive: true });
    }

    // Convert to buffer and write
    const buffer = Buffer.from(await file.arrayBuffer());
    const filename = `${crypto.randomUUID()}.${file.name.split('.').pop()}`;
    await writeFile(`${uploadDir}/${filename}`, buffer);

    return { success: true, filename };
  }
};

State of the Art

Old Approach Current Approach When Changed Impact
navigator.getUserMedia navigator.mediaDevices.getUserMedia 2017+ Old API deprecated, use MediaDevices interface
readFileSync for serving SvelteKit read() helper SvelteKit 2.4 Better streaming, works in serverless
Base64 data URLs Blob + Object URLs Always preferred 33% smaller, faster parsing
jimp for Node.js images Sharp Ongoing Sharp 20x faster, better format support

Deprecated/outdated:

  • navigator.getUserMedia(): Deprecated, use navigator.mediaDevices.getUserMedia()
  • Canvas toDataURL() for uploads: Use toBlob() for better performance
  • Storing files in static/: Build process overwrites; use data/ directory

Open Questions

Things that couldn't be fully resolved:

  1. Thumbnail dimensions

    • What we know: 150x150 is common for list thumbnails
    • What's unclear: Exact dimensions depend on UI design
    • Recommendation: Start with 150x150 cover crop, adjust based on visual testing
  2. Camera button visibility on desktop

    • What we know: Can detect camera with enumerateDevices()
    • What's unclear: Whether to show camera button proactively or only after first use
    • Recommendation: Show camera button, handle no-camera gracefully with error message
  3. Upload progress indication

    • What we know: fetch() doesn't support upload progress natively
    • What's unclear: Whether optimistic preview is sufficient UX
    • Recommendation: Show optimistic preview immediately; if progress needed, use XMLHttpRequest

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Sharp and browser APIs are well-documented, widely used
  • Architecture: HIGH - SvelteKit patterns verified from official sources
  • Pitfalls: HIGH - Based on MDN documentation and established best practices
  • Lightbox choice: MEDIUM - svelte-lightbox is well-maintained but alternatives exist

Research date: 2026-01-29 Valid until: 2026-03-01 (Sharp and browser APIs are stable)