Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
575 changes: 575 additions & 0 deletions docs/superpowers/plans/2026-07-31-camera-capture.md

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions docs/superpowers/specs/2026-07-31-camera-capture-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Camera Capture — Design Spec

**Status:** Approved (brainstorming complete)
**Date:** 2026-07-31
**Author:** brainstormed with Kresna
**Related:** reuses the OCR pipeline (`OcrWorkbench`) from `2026-07-30-image-ocr-design.md`.

## Goal

Let users capture an image from their **webcam or phone camera** as an input alongside upload/paste. Surface it three ways from one reusable component:
1. a **"Use camera"** option in the shared `OcrWorkbench` (so both **Image → Text (OCR)** and **Receipt Scanner** gain it), and
2. a dedicated **"Camera Capture"** tool that snaps a photo and offers Download / Copy / Edit in Annotator.

Everything is client-side; the camera frame never leaves the browser.

## Locked Decisions

1. **Access method:** live camera via `getUserMedia`, **with a native `<input capture>` fallback** when the live camera is unavailable/denied.
2. **Camera control:** default to the **rear camera** (`facingMode: 'environment'`), with a **Switch camera** button when more than one camera exists.
3. **Reusable component:** a single `CameraCapture` component + `useCamera` hook, consumed by `OcrWorkbench` and the new tool. It hands back a `File` via callback; the host decides what to do with it.
4. **Standalone tool output:** show the captured photo with **Download + Copy + Edit in Annotator** (mirrors the Screenshot tool).
5. **Capture UI:** an **in-page panel** (not a full-screen modal) that replaces the input area while active.
6. **Capture format:** **JPEG** (`image/jpeg`, quality ~0.92) — smaller for photographs.

## Architecture

A `useCamera` hook owns the `MediaStream` lifecycle. A `CameraCapture` component renders the live preview + controls and produces a `File`. Two hosts consume it:

```
┌─ OcrWorkbench → onCapture(file) → onDrop([file]) → existing OCR pipeline
CameraCapture ─(File)────┤
(useCamera hook) └─ CameraTool → onCapture(file) → result: Download / Copy / Edit in Annotator
```

### Components

| File | Responsibility | Tested by |
|------|----------------|-----------|
| `src/hooks/useCamera.ts` (create) | Own `getUserMedia`: `start(facingMode)`, `stop()`, `switchCamera()`; expose `stream`, `error` (reason-typed), `hasMultiple`, `facingMode`. Stop all tracks on `stop()` and unmount. Detect >1 camera via `enumerateDevices`. | Mock `navigator.mediaDevices` |
| `src/tools/image/camera.lib.ts` (create) | Pure `frameToFile(video: HTMLVideoElement, name?: string): Promise<File>` — draw the current video frame to a canvas → JPEG blob → File. | Mock canvas/video minimally |
| `src/islands/image/CameraCapture.tsx` (create) | Live `<video>` preview + **Capture** / **Switch camera** / **Cancel**. On error, render the native `<input type="file" accept="image/*" capture="environment">` fallback. Prop: `onCapture(file: File) => void`, `onCancel() => void`. | Build + manual |
| `src/islands/image/OcrWorkbench.tsx` (modify) | Add a **Use camera** button; when open, render `CameraCapture`; on capture call the existing `onDrop([file])`. | Build + manual |
| `src/islands/image/CameraTool.tsx` (create) | Standalone tool: `CameraCapture` → on capture show `ImageResult` + `CopyImageButton` + `EditInAnnotatorButton`; "Retake" resets. | Build + manual |
| `src/registry/tools.ts` (modify) | Register `camera-capture` ("Camera Capture", Image, `status: 'beta'`). | Build |

### Data Model / Interfaces

```ts
// useCamera
type CameraErrorReason = 'insecure' | 'denied' | 'notfound' | 'unsupported' | 'unknown';
interface UseCamera {
videoRef: React.RefObject<HTMLVideoElement>;
stream: MediaStream | null;
error: { reason: CameraErrorReason; message: string } | null;
hasMultiple: boolean;
facingMode: 'environment' | 'user';
start: () => Promise<void>;
stop: () => void;
switchCamera: () => Promise<void>;
}

// camera.lib
function frameToFile(video: HTMLVideoElement, name?: string): Promise<File>; // image/jpeg
```

## UX / Data Flow

1. In `OcrWorkbench` (and the Camera tool) the user clicks **Use camera**.
2. `useCamera.start()` requests the rear camera. On success, the in-page panel shows the live `<video>` preview with **Capture**, **Switch camera** (only if `hasMultiple`), and **Cancel**.
3. **Capture** → `frameToFile(video)` → the stream is stopped → host gets the `File`:
- **OCR/Receipt:** `onDrop([file])` — the photo enters the normal preprocess→OCR flow (downscale, rotate, cleanup, run).
- **Camera tool:** show the photo via `ImageResult` with Download / Copy / Edit in Annotator; **Retake** reopens the camera.
4. **Cancel** stops the stream and returns to the dropzone/idle state.

## Error Handling (reason-specific)

`useCamera` maps failures to a typed reason + message; the UI shows the message and offers the native-input fallback:

- **Not secure context** (`insecure`) → "Camera needs a secure (https) connection." + fallback.
- **Permission denied** (`denied`, `NotAllowedError`) → "Camera access was blocked — allow it in your browser settings, or use your device camera." + fallback.
- **No camera** (`notfound`, `NotFoundError`/`OverconstrainedError`) → "No camera found." + fallback.
- **getUserMedia unsupported** (`unsupported`) → "This browser can't open the camera." + fallback.
- The **native fallback** is `<input type="file" accept="image/*" capture="environment">`: on phones it opens the OS camera app; selecting a photo calls the same `onCapture(file)`.
- **Lifecycle:** every `MediaStreamTrack` is stopped on capture, cancel, error, and component unmount — no lingering camera indicator.

## Testing

- **`useCamera` (Vitest):** mock `navigator.mediaDevices.getUserMedia` / `enumerateDevices`:
- `start()` sets `stream`; a rejected `getUserMedia` with `NotAllowedError` → `error.reason === 'denied'`; missing `mediaDevices` → `unsupported`.
- `stop()` calls `stop()` on every track.
- `switchCamera()` toggles `facingMode` and re-requests.
- `hasMultiple` true when `enumerateDevices` reports ≥2 `videoinput`.
- **`camera.lib` (`frameToFile`):** with a stubbed video (`videoWidth/Height`) and canvas, returns a `File` of type `image/jpeg` with the expected dimensions.
- **Islands** stay thin — build + manual smoke: laptop live capture; phone rear + switch; denied → native fallback; captured photo flows into OCR and into the standalone result.

## Constraints & Non-Functional

- **Privacy:** frames never leave the browser; no uploads.
- **Secure context:** `getUserMedia` requires HTTPS — production is HTTPS; the fallback covers any insecure/denied case.
- **No new deps:** browser `getUserMedia` + canvas only.
- **Reuses** existing `downloadService`, `CopyImageButton`, `EditInAnnotatorButton`, `ImageResult`, and the `onDrop(File[])` pipeline — no downstream changes.

## Out of Scope

- Torch/flash toggle, resolution/aspect picker.
- Continuous scanning / auto-capture / edge detection / auto-crop.
- Barcode/QR reading (there's already a QR reader tool).
- Desktop (Tauri) native camera — the web `getUserMedia` path works there too; no special-casing.
66 changes: 66 additions & 0 deletions src/hooks/useCamera.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCamera } from './useCamera';

const stop = vi.fn();
const track = { stop };
class FakeStream {
getTracks() { return [track]; }
}

function mockMediaDevices(over: Partial<Record<string, unknown>> = {}) {
Object.defineProperty(global.navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn().mockResolvedValue(new FakeStream()),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'videoinput' }, { kind: 'videoinput' }, { kind: 'audioinput' },
]),
...over,
},
});
}

beforeEach(() => { stop.mockClear(); mockMediaDevices(); });
afterEach(() => { vi.restoreAllMocks(); });

describe('useCamera', () => {
it('start() acquires a stream and detects multiple cameras', async () => {
const { result } = renderHook(() => useCamera());
await act(async () => { await result.current.start(); });
expect(result.current.stream).not.toBeNull();
expect(result.current.hasMultiple).toBe(true);
expect(result.current.error).toBeNull();
});

it('stop() stops every track', async () => {
const { result } = renderHook(() => useCamera());
await act(async () => { await result.current.start(); });
act(() => { result.current.stop(); });
expect(stop).toHaveBeenCalled();
expect(result.current.stream).toBeNull();
});

it('maps a denied permission to reason "denied"', async () => {
const err = Object.assign(new Error('denied'), { name: 'NotAllowedError' });
mockMediaDevices({ getUserMedia: vi.fn().mockRejectedValue(err) });
const { result } = renderHook(() => useCamera());
await act(async () => { await result.current.start(); });
expect(result.current.error?.reason).toBe('denied');
});

it('reports "unsupported" when mediaDevices is missing', async () => {
Object.defineProperty(global.navigator, 'mediaDevices', { configurable: true, value: undefined });
const { result } = renderHook(() => useCamera());
await act(async () => { await result.current.start(); });
expect(result.current.error?.reason).toBe('unsupported');
});

it('switchCamera() flips facingMode', async () => {
const { result } = renderHook(() => useCamera());
await act(async () => { await result.current.start(); });
expect(result.current.facingMode).toBe('environment');
await act(async () => { await result.current.switchCamera(); });
expect(result.current.facingMode).toBe('user');
});
});
78 changes: 78 additions & 0 deletions src/hooks/useCamera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from 'react';

export type CameraErrorReason = 'insecure' | 'denied' | 'notfound' | 'unsupported' | 'unknown';
export interface CameraError { reason: CameraErrorReason; message: string }

const MESSAGES: Record<CameraErrorReason, string> = {
insecure: 'Camera needs a secure (https) connection.',
denied: 'Camera access was blocked — allow it in your browser settings, or use your device camera.',
notfound: 'No camera was found on this device.',
unsupported: 'This browser can’t open the camera.',
unknown: 'Could not start the camera.',
};

function classify(err: unknown): CameraErrorReason {
const name = err instanceof Error ? err.name : '';
if (name === 'NotAllowedError' || name === 'SecurityError') return 'denied';
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'notfound';
return 'unknown';
}

export function useCamera() {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<CameraError | null>(null);
const [hasMultiple, setHasMultiple] = useState(false);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');

const stop = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setStream(null);
}, []);

const open = useCallback(async (mode: 'environment' | 'user') => {
setError(null);
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setError({ reason: 'unsupported', message: MESSAGES.unsupported });
return;
}
if (typeof window !== 'undefined' && window.isSecureContext === false) {
setError({ reason: 'insecure', message: MESSAGES.insecure });
return;
}
try {
const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: mode }, audio: false });
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = s;
setStream(s);
setFacingMode(mode);
try {
const devices = await navigator.mediaDevices.enumerateDevices();
setHasMultiple(devices.filter((d) => d.kind === 'videoinput').length > 1);
} catch {
setHasMultiple(false);
}
} catch (err) {
const reason = classify(err);
setError({ reason, message: MESSAGES[reason] });
}
}, []);

const start = useCallback(() => open('environment'), [open]);
const switchCamera = useCallback(
() => open(facingMode === 'environment' ? 'user' : 'environment'),
[open, facingMode],
);

// Attach the stream to the <video> element whenever it changes.
useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = stream;
}, [stream]);

// Always release the camera on unmount.
useEffect(() => () => stop(), [stop]);

return { videoRef, stream, error, hasMultiple, facingMode, start, stop, switchCamera };
}
75 changes: 75 additions & 0 deletions src/islands/image/CameraCapture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { useCamera } from '@/hooks/useCamera';
import { frameToFile } from '@/tools/image/camera.lib';

export default function CameraCapture({
onCapture,
onCancel,
}: {
onCapture: (file: File) => void;
onCancel: () => void;
}) {
const { videoRef, stream, error, hasMultiple, start, stop, switchCamera } = useCamera();
const fileInputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);

// Open the camera on mount; release on unmount.
useEffect(() => { start(); return () => stop(); }, [start, stop]);

// Play the stream once attached.
useEffect(() => {
if (stream && videoRef.current) videoRef.current.play().catch(() => {});
}, [stream, videoRef]);

const capture = async () => {
if (!videoRef.current) return;
setBusy(true);
try {
const file = await frameToFile(videoRef.current);
stop();
onCapture(file);
} catch {
setBusy(false);
}
};

const cancel = () => { stop(); onCancel(); };

const onFallbackFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onCapture(file);
};

return (
<div className="space-y-3 border-2 border-border p-3">
{error ? (
<div className="space-y-2">
<Alert variant="error">{error.message}</Alert>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => fileInputRef.current?.click()}>Use device camera</Button>
<Button variant="ghost" onClick={cancel}>Cancel</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={onFallbackFile}
className="hidden"
/>
</div>
) : (
<div className="space-y-3">
<video ref={videoRef} playsInline muted className="max-h-96 w-auto border-2 border-border" />
<div className="flex flex-wrap gap-2">
<Button onClick={capture} disabled={busy || !stream}>{busy ? 'Capturing…' : 'Capture'}</Button>
{hasMultiple && <Button variant="secondary" onClick={switchCamera}>Switch camera</Button>}
<Button variant="ghost" onClick={cancel}>Cancel</Button>
</div>
</div>
)}
</div>
);
}
39 changes: 39 additions & 0 deletions src/islands/image/CameraTool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { ImageResult } from '@/components/ui/ImageResult';
import { CopyImageButton } from '@/components/ui/CopyImageButton';
import { EditInAnnotatorButton } from '@/components/ui/EditInAnnotatorButton';
import CameraCapture from './CameraCapture';

export default function CameraTool() {
const [photo, setPhoto] = useState<File | null>(null);
const [capturing, setCapturing] = useState(true);

const retake = () => { setPhoto(null); setCapturing(true); };

return (
<div className="space-y-4">
{capturing && (
<CameraCapture
onCapture={(file) => { setPhoto(file); setCapturing(false); }}
onCancel={() => setCapturing(false)}
/>
)}

{!capturing && !photo && (
<Button onClick={() => setCapturing(true)}>Open camera</Button>
)}

{photo && (
<div className="space-y-2">
<ImageResult blob={photo} filename={photo.name} />
<div className="flex flex-wrap gap-2">
<CopyImageButton blob={photo} />
<EditInAnnotatorButton blob={photo} filename={photo.name} />
<Button variant="secondary" onClick={retake}>Retake</Button>
</div>
</div>
)}
</div>
);
}
Loading
Loading