Skip to content
198 changes: 198 additions & 0 deletions docs/PHASE_33_SCAN_OCR_PLAN.md

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions src/hooks/__tests__/useCameraCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCameraCapture } from '../useCameraCapture';

function makeStream() {
const track = { stop: vi.fn() };
const stream = { getTracks: () => [track] } as unknown as MediaStream;
return { stream, track };
}

let getUserMedia: ReturnType<typeof vi.fn>;

beforeEach(() => {
getUserMedia = vi.fn().mockImplementation(async () => makeStream().stream);
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia },
configurable: true,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('useCameraCapture', () => {
it('starts in idle status', () => {
const { result } = renderHook(() => useCameraCapture());
expect(result.current.status).toBe('idle');
expect(result.current.error).toBeNull();
});

it('start requests the rear camera by default and goes active', async () => {
const { result } = renderHook(() => useCameraCapture());
await act(async () => {
await result.current.start();
});
expect(getUserMedia).toHaveBeenCalledWith({ video: { facingMode: 'environment' }, audio: false });
expect(result.current.status).toBe('active');
});

it('start honours a user-facing camera request', async () => {
const { result } = renderHook(() => useCameraCapture());
await act(async () => {
await result.current.start({ facingMode: 'user' });
});
expect(getUserMedia).toHaveBeenCalledWith({ video: { facingMode: 'user' }, audio: false });
});

it('sets error status when permission is denied', async () => {
getUserMedia.mockRejectedValueOnce(new Error('denied'));
const { result } = renderHook(() => useCameraCapture());
let ok = true;
await act(async () => {
ok = await result.current.start();
});
expect(ok).toBe(false);
expect(result.current.status).toBe('error');
expect(result.current.error?.message).toBe('denied');
});

it('capture returns null before the camera is started', async () => {
const { result } = renderHook(() => useCameraCapture());
const out = await result.current.capture();
expect(out).toBeNull();
});

it('capture draws the current frame to a blob', async () => {
const drawImage = vi.fn();
const getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D);
const toBlobSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
this: HTMLCanvasElement,
cb: BlobCallback
) {
cb(new Blob(['img'], { type: 'image/png' }));
});

const { result } = renderHook(() => useCameraCapture());
act(() => {
result.current.videoRef.current = {
videoWidth: 640,
videoHeight: 480,
play: vi.fn().mockResolvedValue(undefined),
} as unknown as HTMLVideoElement;
});
await act(async () => {
await result.current.start();
});

const out = await result.current.capture('image/png');
expect(out).not.toBeNull();
expect(out!.width).toBe(640);
expect(out!.height).toBe(480);
expect(out!.mimeType).toBe('image/png');
expect(drawImage).toHaveBeenCalled();

getContextSpy.mockRestore();
toBlobSpy.mockRestore();
});

it('capture returns null when the video has no dimensions yet', async () => {
const { result } = renderHook(() => useCameraCapture());
act(() => {
result.current.videoRef.current = {
videoWidth: 0,
videoHeight: 0,
play: vi.fn().mockResolvedValue(undefined),
} as unknown as HTMLVideoElement;
});
await act(async () => {
await result.current.start();
});
expect(await result.current.capture()).toBeNull();
});

it('stop tears down the stream tracks', async () => {
const { stream, track } = makeStream();
getUserMedia.mockResolvedValueOnce(stream);
const { result } = renderHook(() => useCameraCapture());
await act(async () => {
await result.current.start();
});
act(() => {
result.current.stop();
});
expect(track.stop).toHaveBeenCalled();
expect(result.current.status).toBe('idle');
});

it('start is idempotent while active and does not request the camera twice', async () => {
const { result } = renderHook(() => useCameraCapture());
await act(async () => {
await result.current.start();
});
await act(async () => {
expect(await result.current.start()).toBe(true);
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
});

it('tears down a partially-acquired stream when play() rejects', async () => {
const { stream, track } = makeStream();
getUserMedia.mockResolvedValueOnce(stream);
const { result } = renderHook(() => useCameraCapture());
act(() => {
result.current.videoRef.current = {
videoWidth: 640,
videoHeight: 480,
play: vi.fn().mockRejectedValue(new Error('play failed')),
} as unknown as HTMLVideoElement;
});
let ok = true;
await act(async () => {
ok = await result.current.start();
});
expect(ok).toBe(false);
expect(result.current.status).toBe('error');
expect(track.stop).toHaveBeenCalled();
});

it('serializes concurrent start calls into a single getUserMedia request', async () => {
const { result } = renderHook(() => useCameraCapture());
await act(async () => {
await Promise.all([result.current.start(), result.current.start()]);
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe('active');
});

it('discards a stream that arrives after stop() and stays idle', async () => {
const { stream, track } = makeStream();
let resolveGum!: (s: MediaStream) => void;
getUserMedia.mockImplementationOnce(
() =>
new Promise<MediaStream>((res) => {
resolveGum = res;
})
);
const { result } = renderHook(() => useCameraCapture());
let startPromise!: Promise<boolean>;
act(() => {
startPromise = result.current.start();
});
act(() => {
result.current.stop();
});
let ok = true;
await act(async () => {
resolveGum(stream);
ok = await startPromise;
});
expect(ok).toBe(false);
expect(track.stop).toHaveBeenCalled();
expect(result.current.status).toBe('idle');
});

it('reports the actual blob mime type when the browser falls back to png', async () => {
const getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D);
const toBlobSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
this: HTMLCanvasElement,
cb: BlobCallback
) {
cb(new Blob(['img'], { type: 'image/png' }));
});

const { result } = renderHook(() => useCameraCapture());
act(() => {
result.current.videoRef.current = {
videoWidth: 640,
videoHeight: 480,
play: vi.fn().mockResolvedValue(undefined),
} as unknown as HTMLVideoElement;
});
await act(async () => {
await result.current.start();
});

const out = await result.current.capture('image/webp');
expect(out!.mimeType).toBe('image/png');

getContextSpy.mockRestore();
toBlobSpy.mockRestore();
});

it('capture returns null when no 2d context is available', async () => {
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const { result } = renderHook(() => useCameraCapture());
act(() => {
result.current.videoRef.current = {
videoWidth: 640,
videoHeight: 480,
play: vi.fn().mockResolvedValue(undefined),
} as unknown as HTMLVideoElement;
});
await act(async () => {
await result.current.start();
});
expect(await result.current.capture()).toBeNull();
getContextSpy.mockRestore();
});
});
124 changes: 124 additions & 0 deletions src/hooks/useCameraCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';

export type CameraStatus = 'idle' | 'active' | 'error';

export interface CaptureResult {
blob: Blob;
mimeType: string;
width: number;
height: number;
}

export interface StartCameraOptions {
/** Prefer the rear ('environment') camera for scanning paper; falls back if unavailable. */
facingMode?: 'environment' | 'user';
}

export interface UseCameraCaptureReturn {
status: CameraStatus;
error: Error | null;
/** Attach to a <video autoPlay playsInline muted> element to preview the live camera. */
videoRef: React.RefObject<HTMLVideoElement | null>;
start: (opts?: StartCameraOptions) => Promise<boolean>;
capture: (mimeType?: string, quality?: number) => Promise<CaptureResult | null>;
stop: () => void;
}

/**
* Live camera preview + still-frame capture for scanning paper work. A still image
* (not a recording) is grabbed from the preview onto a canvas; the caller stores the
* resulting blob via scanStore. Mirrors useMediaRecorder's permission/stream/cleanup
* handling but is video-still oriented.
*/
export function useCameraCapture(): UseCameraCaptureReturn {
const [status, setStatus] = useState<CameraStatus>('idle');
const [error, setError] = useState<Error | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const startPromiseRef = useRef<Promise<boolean> | null>(null);
// Bumped by stop()/unmount so a getUserMedia request still in flight can tell it
// was superseded and release its stream instead of attaching it after teardown.
const genRef = useRef(0);

const stop = useCallback(() => {
genRef.current += 1;
startPromiseRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null;
setStatus('idle');
}, []);

const start = useCallback((opts: StartCameraOptions = {}) => {
if (streamRef.current) return Promise.resolve(true);
if (startPromiseRef.current) return startPromiseRef.current;

const gen = genRef.current;
const run = (async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: opts.facingMode ?? 'environment' },
audio: false,
});
if (gen !== genRef.current) {
stream.getTracks().forEach((t) => t.stop());
return false;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play?.();
}
if (gen !== genRef.current) {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null;
return false;
}
setStatus('active');
setError(null);
return true;
} catch (e) {
if (gen === genRef.current) {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setError(e instanceof Error ? e : new Error(String(e)));
setStatus('error');
}
return false;
} finally {
if (gen === genRef.current) startPromiseRef.current = null;
}
})();
startPromiseRef.current = run;
return run;
}, []);

const capture = useCallback((mimeType = 'image/png', quality?: number) => {
const video = videoRef.current;
if (!video || !streamRef.current) return Promise.resolve<CaptureResult | null>(null);

const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) return Promise.resolve<CaptureResult | null>(null);

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.resolve<CaptureResult | null>(null);
ctx.drawImage(video, 0, 0, width, height);

return new Promise<CaptureResult | null>((resolve) => {
canvas.toBlob(
(blob) => resolve(blob ? { blob, mimeType: blob.type || mimeType, width, height } : null),
mimeType,
quality
);
});
}, []);

useEffect(() => stop, [stop]);

return { status, error, videoRef, start, capture, stop };
}
Loading
Loading