Skip to content
Closed
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
130 changes: 130 additions & 0 deletions scripts/test-camera-capture-single-encode.mjs
Original file line number Diff line number Diff line change
@@ -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();'));
});
});
82 changes: 59 additions & 23 deletions src/renderer/src/CameraExtension.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -78,7 +84,7 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ onClose }) => {
const [stream, setStream] = useState<MediaStream | null>(null);
const [showActions, setShowActions] = useState(false);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [capturePreviewDataUrl, setCapturePreviewDataUrl] = useState<string | null>(null);
const [capturePreviewUrl, setCapturePreviewUrl] = useState<string | null>(null);
const [capturePreviewVisible, setCapturePreviewVisible] = useState(false);
const [captureStatus, setCaptureStatus] = useState<CaptureStatus | null>(null);
const [flashVisible, setFlashVisible] = useState(false);
Expand All @@ -95,6 +101,11 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ onClose }) => {
const startRequestIdRef = useRef(0);
const unmountedRef = useRef(false);
const streamRef = useRef<MediaStream | null>(null);
const capturePreviewUrlManagerRef = useRef<CapturePreviewUrlManager | null>(null);

if (capturePreviewUrlManagerRef.current === null) {
capturePreviewUrlManagerRef.current = createCapturePreviewUrlManager();
}

const clearTransientUi = useCallback(() => {
if (captureNoticeTimerRef.current != null) {
Expand All @@ -115,6 +126,45 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ 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();
Expand Down Expand Up @@ -284,22 +334,6 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ 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);
Expand All @@ -316,9 +350,10 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ 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) {
Expand Down Expand Up @@ -349,7 +384,7 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ 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 {
Expand All @@ -363,6 +398,7 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ onClose }) => {
unmountedRef.current = true;
startRequestIdRef.current += 1;
clearTransientUi();
capturePreviewUrlManagerRef.current?.dispose();
const currentStream = streamRef.current;
streamRef.current = null;
stopMediaStream(currentStream);
Expand Down Expand Up @@ -586,14 +622,14 @@ const CameraExtension: React.FC<CameraExtensionProps> = ({ onClose }) => {
<span className="truncate max-w-[240px]">{selectedCameraLabel}</span>
</div>

{capturePreviewDataUrl ? (
{capturePreviewUrl ? (
<div
className={`absolute right-3 bottom-3 w-28 h-20 rounded-lg border border-white/20 overflow-hidden bg-black/80 shadow-xl transition-opacity duration-300 ${
capturePreviewVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<img
src={capturePreviewDataUrl}
src={capturePreviewUrl}
alt="Latest capture"
className="w-full h-full object-cover"
draggable={false}
Expand Down
73 changes: 73 additions & 0 deletions src/renderer/src/camera-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export interface CapturePreviewUrlApi {
createObjectURL: (blob: Blob) => 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<HTMLCanvasElement, 'toBlob'>): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/png');
});
}
Loading