diff --git a/docs/superpowers/plans/2026-07-31-camera-capture.md b/docs/superpowers/plans/2026-07-31-camera-capture.md new file mode 100644 index 0000000..9d756ee --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-camera-capture.md @@ -0,0 +1,575 @@ +# Camera Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add webcam/phone-camera capture as an input: a reusable `CameraCapture` component wired into the shared `OcrWorkbench` (OCR + Receipt Scanner) and exposed as a standalone "Camera Capture" tool. + +**Architecture:** A `useCamera` hook owns the `MediaStream` lifecycle; a pure `frameToFile` helper converts a video frame to a JPEG `File`; `CameraCapture` renders live preview + controls (with a native `` fallback); two hosts consume it — `OcrWorkbench` (→ existing `onDrop`) and `CameraTool` (→ Download/Copy/Edit-in-Annotator). + +**Tech Stack:** React islands (Astro); browser `getUserMedia` + canvas; existing `downloadService`, `CopyImageButton`, `EditInAnnotatorButton`, `ImageResult`; Vitest. + +## Global Constraints + +- **Branch:** `feat/camera-capture` (spec already committed here). +- **Reusable component, three surfaces:** one `CameraCapture` used by `OcrWorkbench` and `CameraTool`. (Spec §Architecture). +- **Rear default + switch:** `facingMode: 'environment'`; Switch button only when `hasMultiple`. (Spec §Locked Decision 2). +- **Fallback:** on any getUserMedia failure, render ``. (Spec §Error Handling). +- **Format:** JPEG, quality 0.92. (Spec §Locked Decision 6). +- **Lifecycle:** stop every track on capture, cancel, error, and unmount — no lingering camera. (Spec §Error Handling). +- **Privacy/secure context:** frames never leave the browser; `getUserMedia` needs HTTPS (prod is HTTPS; fallback covers the rest). +- **Test style:** Vitest; mock `navigator.mediaDevices` per `src/services/capture/browser.test.ts`. Pure helper unit-tested; islands stay thin. +- **Lint:** `npm run lint` 0 errors; no `any` in new source. + +## File Structure + +| File | Responsibility | +|------|----------------| +| `src/tools/image/camera.lib.ts` (create) | Pure `frameToFile(video, name?)`. | +| `src/tools/image/camera.lib.test.ts` (create) | Frame→File tests. | +| `src/hooks/useCamera.ts` (create) | `MediaStream` lifecycle hook. | +| `src/hooks/useCamera.test.ts` (create) | Hook tests (mock mediaDevices). | +| `src/islands/image/CameraCapture.tsx` (create) | Live preview + controls + native fallback. | +| `src/islands/image/OcrWorkbench.tsx` (modify) | "Use camera" button → `CameraCapture` → `onDrop`. | +| `src/islands/image/CameraTool.tsx` (create) | Standalone tool: capture → result actions. | +| `src/registry/tools.ts` (modify) | Register `camera-capture`. | + +--- + +### Task 1: `frameToFile` helper (`camera.lib.ts`) + +Pure conversion of a video frame to a JPEG `File`. Isolated + unit-tested. + +**Files:** +- Create: `src/tools/image/camera.lib.ts` +- Test: `src/tools/image/camera.lib.test.ts` + +**Interfaces:** +- Produces: `frameToFile(video: HTMLVideoElement, name?: string): Promise` — draws `video` (using `videoWidth`/`videoHeight`) to a canvas, encodes `image/jpeg` q0.92, returns a `File` named `name ?? 'camera-capture.jpg'`. + +- [ ] **Step 1: Write the failing test** + +Create `src/tools/image/camera.lib.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { frameToFile } from './camera.lib'; + +// Minimal canvas mock: records the size it was asked to draw and yields a blob. +beforeEach(() => { + const ctx = { drawImage: vi.fn() }; + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag !== 'canvas') return document.createElement(tag); + return { + width: 0, + height: 0, + getContext: () => ctx, + toBlob: (cb: (b: Blob | null) => void) => cb(new Blob(['x'], { type: 'image/jpeg' })), + } as unknown as HTMLCanvasElement; + }); +}); + +function fakeVideo(w: number, h: number): HTMLVideoElement { + return { videoWidth: w, videoHeight: h } as HTMLVideoElement; +} + +describe('frameToFile', () => { + it('returns a JPEG File sized to the video frame', async () => { + const file = await frameToFile(fakeVideo(640, 480)); + expect(file).toBeInstanceOf(File); + expect(file.type).toBe('image/jpeg'); + expect(file.name).toBe('camera-capture.jpg'); + }); + + it('uses a custom filename', async () => { + const file = await frameToFile(fakeVideo(100, 100), 'shot.jpg'); + expect(file.name).toBe('shot.jpg'); + }); + + it('rejects when the frame has no dimensions', async () => { + await expect(frameToFile(fakeVideo(0, 0))).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/tools/image/camera.lib.test.ts` +Expected: FAIL — `./camera.lib` not found. + +- [ ] **Step 3: Write the implementation** + +Create `src/tools/image/camera.lib.ts`: + +```ts +/** Capture the current frame of a playing