diff --git a/scripts/test-camera-capture-single-encode.mjs b/scripts/test-camera-capture-single-encode.mjs new file mode 100644 index 00000000..3e493ccd --- /dev/null +++ b/scripts/test-camera-capture-single-encode.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cameraExtensionPath = path.join(root, 'src/renderer/src/CameraExtension.tsx'); +const cameraCapturePath = path.join(root, 'src/renderer/src/camera-capture.ts'); + +const { + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} = await importTs(cameraCapturePath); + +function makeMockCanvas(blob = new Blob(['fake-png'], { type: 'image/png' })) { + const calls = { + toBlob: [], + toDataURL: [], + }; + + return { + calls, + toBlob(callback, type) { + calls.toBlob.push(type); + callback(blob); + }, + toDataURL(type) { + calls.toDataURL.push(type); + return 'data:image/png;base64,legacy-preview'; + }, + }; +} + +function makeMockUrlApi() { + const created = []; + const revoked = []; + + return { + created, + revoked, + api: { + createObjectURL(blob) { + const objectUrl = `blob:supercmd-test-${created.length + 1}`; + created.push({ objectUrl, blob }); + return objectUrl; + }, + revokeObjectURL(objectUrl) { + revoked.push(objectUrl); + }, + }, + }; +} + +test('Camera capture single-encode path', async (t) => { + await t.test('mock canvas catches the legacy duplicate encode baseline', async () => { + const canvas = makeMockCanvas(); + + canvas.toDataURL('image/png'); + await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + + assert.deepEqual(canvas.calls.toDataURL, ['image/png']); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + }); + + await t.test('production capture encoding calls toBlob once and never toDataURL', async () => { + const expectedBlob = new Blob(['fake-png'], { type: 'image/png' }); + const canvas = makeMockCanvas(expectedBlob); + + const blob = await encodeCanvasAsPngBlob(canvas); + + assert.equal(blob, expectedBlob); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + assert.deepEqual(canvas.calls.toDataURL, []); + }); + + await t.test('capture preview is shown from an object URL', () => { + const { api, created, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + const blob = new Blob(['preview'], { type: 'image/png' }); + + const preview = createCapturePreview(blob, manager); + + assert.deepEqual(preview, { url: 'blob:supercmd-test-1', visible: true }); + assert.equal(created.length, 1); + assert.equal(created[0].blob, blob); + assert.deepEqual(revoked, []); + }); + + await t.test('object URL is revoked when preview is cleared', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + assert.equal(manager.getCurrentUrl(), 'blob:supercmd-test-1'); + + manager.clear(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('object URL is revoked when preview manager is disposed on unmount', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + manager.dispose(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('component wires the object URL preview to clear and unmount paths', () => { + const source = fs.readFileSync(cameraExtensionPath, 'utf8'); + + assert.ok(source.includes('const captureBlob = await encodeCanvasAsPngBlob(canvas);')); + assert.ok(source.includes("from './camera-capture';")); + assert.ok(!source.includes('.toDataURL(')); + assert.ok(source.includes('src={capturePreviewUrl}')); + assert.ok(source.includes('clearCapturePreview();')); + assert.ok(source.includes('capturePreviewUrlManagerRef.current?.dispose();')); + }); +}); diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..118ce3dd 100644 --- a/src/renderer/src/CameraExtension.tsx +++ b/src/renderer/src/CameraExtension.tsx @@ -1,6 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, Camera, Image, RefreshCw, RotateCcw, Settings, Video, X } from 'lucide-react'; import ExtensionActionFooter from './components/ExtensionActionFooter'; +import { + type CapturePreviewUrlManager, + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} from './camera-capture'; interface CameraExtensionProps { onClose: () => void; @@ -78,7 +84,7 @@ const CameraExtension: React.FC = ({ onClose }) => { const [stream, setStream] = useState(null); const [showActions, setShowActions] = useState(false); const [selectedActionIndex, setSelectedActionIndex] = useState(0); - const [capturePreviewDataUrl, setCapturePreviewDataUrl] = useState(null); + const [capturePreviewUrl, setCapturePreviewUrl] = useState(null); const [capturePreviewVisible, setCapturePreviewVisible] = useState(false); const [captureStatus, setCaptureStatus] = useState(null); const [flashVisible, setFlashVisible] = useState(false); @@ -95,6 +101,11 @@ const CameraExtension: React.FC = ({ onClose }) => { const startRequestIdRef = useRef(0); const unmountedRef = useRef(false); const streamRef = useRef(null); + const capturePreviewUrlManagerRef = useRef(null); + + if (capturePreviewUrlManagerRef.current === null) { + capturePreviewUrlManagerRef.current = createCapturePreviewUrlManager(); + } const clearTransientUi = useCallback(() => { if (captureNoticeTimerRef.current != null) { @@ -115,6 +126,45 @@ const CameraExtension: React.FC = ({ onClose }) => { } }, []); + const clearCapturePreview = useCallback(() => { + capturePreviewUrlManagerRef.current?.clear(); + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + }, []); + + const showCapturePreview = useCallback( + (blob: Blob) => { + const previewUrlManager = capturePreviewUrlManagerRef.current; + if (!previewUrlManager) return; + + if (capturePreviewFadeTimerRef.current != null) { + window.clearTimeout(capturePreviewFadeTimerRef.current); + } + if (capturePreviewClearTimerRef.current != null) { + window.clearTimeout(capturePreviewClearTimerRef.current); + } + + const preview = createCapturePreview(blob, previewUrlManager); + if (!preview.url) { + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + return; + } + + setCapturePreviewUrl(preview.url); + setCapturePreviewVisible(preview.visible); + capturePreviewFadeTimerRef.current = window.setTimeout(() => { + setCapturePreviewVisible(false); + capturePreviewFadeTimerRef.current = null; + }, 5000); + capturePreviewClearTimerRef.current = window.setTimeout(() => { + clearCapturePreview(); + capturePreviewClearTimerRef.current = null; + }, 5300); + }, + [clearCapturePreview] + ); + const refocusCameraRoot = useCallback(() => { window.requestAnimationFrame(() => { rootRef.current?.focus(); @@ -284,22 +334,6 @@ const CameraExtension: React.FC = ({ onClose }) => { context.drawImage(video, 0, 0, width, height); } - setCapturePreviewDataUrl(canvas.toDataURL('image/png')); - setCapturePreviewVisible(true); - if (capturePreviewFadeTimerRef.current != null) { - window.clearTimeout(capturePreviewFadeTimerRef.current); - } - if (capturePreviewClearTimerRef.current != null) { - window.clearTimeout(capturePreviewClearTimerRef.current); - } - capturePreviewFadeTimerRef.current = window.setTimeout(() => { - setCapturePreviewVisible(false); - capturePreviewFadeTimerRef.current = null; - }, 5000); - capturePreviewClearTimerRef.current = window.setTimeout(() => { - setCapturePreviewDataUrl(null); - setCapturePreviewClearTimerRef.current = null; - }, 5300); setFlashVisible(true); if (flashTimerRef.current != null) { window.clearTimeout(flashTimerRef.current); @@ -316,9 +350,10 @@ const CameraExtension: React.FC = ({ onClose }) => { const timestamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}_${two(now.getHours())}-${two(now.getMinutes())}-${two(now.getSeconds())}`; const savePath = `${saveDir}/supercmd-capture-${timestamp}.png`; - let captureBlob: Blob | null = await new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), 'image/png'); - }); + const captureBlob = await encodeCanvasAsPngBlob(canvas); + if (captureBlob && !unmountedRef.current) { + showCapturePreview(captureBlob); + } let savedToDisk = false; if (captureBlob) { @@ -349,7 +384,7 @@ const CameraExtension: React.FC = ({ onClose }) => { showCaptureStatus({ kind: 'neutral', text: 'Failed to copy picture to clipboard.' }, 3000); } refocusCameraRoot(); - }, [isHorizontallyFlipped, refocusCameraRoot, showCaptureStatus, stream]); + }, [isHorizontallyFlipped, refocusCameraRoot, showCapturePreview, showCaptureStatus, stream]); const openSystemCameraSettings = useCallback(async () => { try { @@ -363,6 +398,7 @@ const CameraExtension: React.FC = ({ onClose }) => { unmountedRef.current = true; startRequestIdRef.current += 1; clearTransientUi(); + capturePreviewUrlManagerRef.current?.dispose(); const currentStream = streamRef.current; streamRef.current = null; stopMediaStream(currentStream); @@ -586,14 +622,14 @@ const CameraExtension: React.FC = ({ onClose }) => { {selectedCameraLabel} - {capturePreviewDataUrl ? ( + {capturePreviewUrl ? (
Latest capture string; + revokeObjectURL: (objectUrl: string) => void; +} + +export interface CapturePreviewUrlManager { + getCurrentUrl: () => string | null; + show: (blob: Blob) => string | null; + clear: () => void; + dispose: () => void; +} + +function getCapturePreviewUrlApi(): CapturePreviewUrlApi | null { + if ( + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return null; + } + return URL; +} + +export function createCapturePreviewUrlManager( + urlApi: CapturePreviewUrlApi | null = getCapturePreviewUrlApi() +): CapturePreviewUrlManager { + let currentUrl: string | null = null; + + const clear = () => { + const urlToRevoke = currentUrl; + currentUrl = null; + if (!urlToRevoke || !urlApi) return; + try { + urlApi.revokeObjectURL(urlToRevoke); + } catch {} + }; + + return { + getCurrentUrl: () => currentUrl, + show: (blob: Blob) => { + let nextUrl: string | null = null; + if (urlApi) { + try { + nextUrl = urlApi.createObjectURL(blob); + } catch { + nextUrl = null; + } + } + clear(); + currentUrl = nextUrl; + return currentUrl; + }, + clear, + dispose: clear, + }; +} + +export function createCapturePreview( + blob: Blob, + previewUrlManager: CapturePreviewUrlManager +): { url: string | null; visible: boolean } { + const url = previewUrlManager.show(blob); + return { + url, + visible: Boolean(url), + }; +} + +export function encodeCanvasAsPngBlob(canvas: Pick): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png'); + }); +}