892 lines
26 KiB
JavaScript
892 lines
26 KiB
JavaScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildLPRFrameFingerprint,
|
|
buildLPRFrameFingerprintKey,
|
|
captureVideoFrameBlobForLPR,
|
|
getCenteredFocusSourceRect,
|
|
getConstrainedFrameSize,
|
|
getLPRFrameSourceRect,
|
|
getLPRFrameTargetSize,
|
|
getVisualFingerprintDistance,
|
|
getVisibleCoverSourceRect,
|
|
isVideoFrameReadyForLPR,
|
|
LPR_CAMERA_VIDEO_HEIGHT,
|
|
LPR_CAMERA_VIDEO_WIDTH,
|
|
LPR_FRAME_FILE_NAME,
|
|
LPR_FRAME_FOCUS_ASPECT_RATIO,
|
|
LPR_FRAME_JPEG_QUALITY,
|
|
LPR_FRAME_MIME_TYPE,
|
|
LPR_FRAME_SCANNER_FOCUS_SCALE,
|
|
LPR_FRAME_SCANNER_MAX_SIZE,
|
|
LPR_FRAME_SCANNER_JPEG_QUALITY,
|
|
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
|
|
|
|
describe("LPR frame capture", () => {
|
|
it("constrains 16:9 camera frames before encoding", () => {
|
|
expect(getConstrainedFrameSize(1920, 1080)).toEqual({
|
|
width: 1024,
|
|
height: 576,
|
|
});
|
|
});
|
|
|
|
it("uses the full camera frame when rendered video dimensions are unavailable", () => {
|
|
expect(getVisibleCoverSourceRect(1280, 720, 0, 0)).toEqual({
|
|
height: 720,
|
|
width: 1280,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
});
|
|
|
|
it("checks video readiness before camera LPR capture starts", () => {
|
|
expect(
|
|
isVideoFrameReadyForLPR({
|
|
readyState: 1,
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
isVideoFrameReadyForLPR({
|
|
readyState: 2,
|
|
videoHeight: 0,
|
|
videoWidth: 1280,
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
isVideoFrameReadyForLPR({
|
|
readyState: 2,
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it("crops a landscape camera frame to the visible portrait preview before encoding", () => {
|
|
expect(getVisibleCoverSourceRect(1280, 720, 390, 844)).toEqual({
|
|
height: 720,
|
|
width: 333,
|
|
x: 474,
|
|
y: 0,
|
|
});
|
|
expect(getConstrainedFrameSize(333, 720)).toEqual({
|
|
width: 266,
|
|
height: 576,
|
|
});
|
|
});
|
|
|
|
it("crops the visible preview to the centered scanner focus area before encoding", () => {
|
|
expect(
|
|
getCenteredFocusSourceRect({
|
|
height: 1080,
|
|
width: 1920,
|
|
x: 0,
|
|
y: 0,
|
|
})
|
|
).toEqual({
|
|
height: 1080,
|
|
width: 1920,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
expect(getLPRFrameSourceRect(1280, 720, 390, 844)).toEqual({
|
|
height: 159,
|
|
width: 283,
|
|
x: 499,
|
|
y: 281,
|
|
});
|
|
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusCrop: false })).toEqual({
|
|
height: 720,
|
|
width: 333,
|
|
x: 474,
|
|
y: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps the live stream cap aligned with attachment preview max while reducing scanner crop pixels", () => {
|
|
expect(LPR_CAMERA_VIDEO_WIDTH).toBe(1024);
|
|
expect(LPR_CAMERA_VIDEO_HEIGHT).toBe(576);
|
|
expect(getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844)).toEqual({
|
|
height: 128,
|
|
width: 226,
|
|
x: 399,
|
|
y: 224,
|
|
});
|
|
expect(
|
|
getLPRFrameTargetSize(
|
|
getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844, { focusCrop: false }),
|
|
{ focusCrop: false }
|
|
)
|
|
).toEqual({
|
|
height: 576,
|
|
width: 266,
|
|
});
|
|
});
|
|
|
|
it("can tighten the centered scanner crop while keeping a margin around the outline", () => {
|
|
expect(LPR_FRAME_FOCUS_ASPECT_RATIO).toBe(16 / 9);
|
|
expect(LPR_FRAME_SCANNER_FOCUS_SCALE).toBe(0.85);
|
|
expect(
|
|
getCenteredFocusSourceRect(
|
|
{
|
|
height: 720,
|
|
width: 333,
|
|
x: 474,
|
|
y: 0,
|
|
},
|
|
LPR_FRAME_FOCUS_ASPECT_RATIO,
|
|
LPR_FRAME_SCANNER_FOCUS_SCALE
|
|
)
|
|
).toEqual({
|
|
height: 159,
|
|
width: 283,
|
|
x: 499,
|
|
y: 281,
|
|
});
|
|
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusScale: 1 })).toEqual({
|
|
height: 187,
|
|
width: 333,
|
|
x: 474,
|
|
y: 267,
|
|
});
|
|
});
|
|
|
|
it("anchors the scanner crop to a viewport focus rect when available", () => {
|
|
expect(
|
|
getLPRFrameSourceRect(1280, 720, 390, 844, {
|
|
focusViewportRect: {
|
|
height: 234,
|
|
width: 234,
|
|
x: 78,
|
|
y: 205,
|
|
},
|
|
})
|
|
).toEqual({
|
|
height: 159,
|
|
width: 283,
|
|
x: 499,
|
|
y: 195,
|
|
});
|
|
expect(
|
|
getLPRFrameSourceRect(1280, 720, 390, 844, {
|
|
focusViewportRect: {
|
|
height: 234,
|
|
width: 234,
|
|
x: 78,
|
|
y: -100,
|
|
},
|
|
})
|
|
).toEqual({
|
|
height: 159,
|
|
width: 283,
|
|
x: 499,
|
|
y: 0,
|
|
});
|
|
});
|
|
|
|
it("uses a smaller max size for scanner LPR frames than preview frames", () => {
|
|
expect(
|
|
getLPRFrameTargetSize({
|
|
height: 1080,
|
|
width: 1440,
|
|
x: 240,
|
|
y: 0,
|
|
})
|
|
).toEqual({
|
|
height: 288,
|
|
width: LPR_FRAME_SCANNER_MAX_SIZE,
|
|
});
|
|
expect(
|
|
getLPRFrameTargetSize(
|
|
{
|
|
height: 720,
|
|
width: 333,
|
|
x: 474,
|
|
y: 0,
|
|
},
|
|
{ focusCrop: false }
|
|
)
|
|
).toEqual({
|
|
height: 576,
|
|
width: 266,
|
|
});
|
|
});
|
|
|
|
it("captures a focused downscaled JPEG blob frame for multipart LPR uploads", async () => {
|
|
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const toBlob = vi.fn((callback) => callback(blob));
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
captureTimings: {
|
|
drawMs: expect.any(Number),
|
|
encodeMs: expect.any(Number),
|
|
visualFingerprintMs: expect.any(Number),
|
|
},
|
|
filename: LPR_FRAME_FILE_NAME,
|
|
fingerprint: `384x216:${blob.size}`,
|
|
getContentFingerprint: expect.any(Function),
|
|
height: 216,
|
|
mimeType: LPR_FRAME_MIME_TYPE,
|
|
width: 384,
|
|
});
|
|
expect(canvas.width).toBe(384);
|
|
expect(canvas.height).toBe(216);
|
|
expect(frame.captureDurationMs).toBeGreaterThanOrEqual(0);
|
|
expect(frame.captureTimings.drawMs).toBeGreaterThanOrEqual(0);
|
|
expect(frame.captureTimings.encodeMs).toBeGreaterThanOrEqual(0);
|
|
expect(frame.captureTimings.visualFingerprintMs).toBeGreaterThanOrEqual(0);
|
|
expect(LPR_FRAME_SCANNER_JPEG_QUALITY).toBe(0.6);
|
|
expect(canvas.getContext).toHaveBeenCalledWith("2d", {
|
|
alpha: false,
|
|
desynchronized: true,
|
|
});
|
|
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
|
|
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_SCANNER_JPEG_QUALITY);
|
|
});
|
|
|
|
it("notifies after drawing the current video frame before JPEG encoding starts", async () => {
|
|
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const events = [];
|
|
const drawImage = vi.fn(() => {
|
|
events.push("draw");
|
|
});
|
|
const toBlob = vi.fn((callback) => {
|
|
events.push("encode");
|
|
callback(blob);
|
|
});
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
const onFrameDrawn = vi.fn(() => {
|
|
events.push("drawn-callback");
|
|
});
|
|
|
|
await captureVideoFrameBlobForLPR(video, canvas, { onFrameDrawn });
|
|
|
|
expect(onFrameDrawn).toHaveBeenCalledTimes(1);
|
|
expect(events).toEqual(["draw", "drawn-callback", "encode"]);
|
|
});
|
|
|
|
it("reuses the 2d encoding context for repeated captures on the same canvas", async () => {
|
|
const blob = new Blob(["cached-context-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
await captureVideoFrameBlobForLPR(video, canvas);
|
|
await captureVideoFrameBlobForLPR(video, canvas);
|
|
|
|
expect(canvas.getContext).toHaveBeenCalledTimes(1);
|
|
expect(drawImage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses a reusable OffscreenCanvas encoder when the browser supports it", async () => {
|
|
const blob = new Blob(["offscreen-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const offscreenCanvases = [];
|
|
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.drawImage = vi.fn();
|
|
this.getContext = vi.fn(() => ({
|
|
drawImage: this.drawImage,
|
|
}));
|
|
this.convertToBlob = vi.fn(() => Promise.resolve(blob));
|
|
offscreenCanvases.push(this);
|
|
});
|
|
const fallbackDrawImage = vi.fn();
|
|
const fallbackToBlob = vi.fn();
|
|
const fallbackCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fallbackDrawImage,
|
|
})),
|
|
toBlob: fallbackToBlob,
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
|
|
try {
|
|
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
|
|
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
fingerprint: `384x216:${blob.size}`,
|
|
height: 216,
|
|
mimeType: LPR_FRAME_MIME_TYPE,
|
|
width: 384,
|
|
});
|
|
expect(secondFrame).toMatchObject({
|
|
blob,
|
|
fingerprint: `384x216:${blob.size}`,
|
|
});
|
|
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
|
|
expect(offscreenConstructor).toHaveBeenCalledWith(1, 1);
|
|
expect(offscreenCanvases[0].width).toBe(384);
|
|
expect(offscreenCanvases[0].height).toBe(216);
|
|
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledTimes(2);
|
|
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
|
|
expect(offscreenCanvases[0].convertToBlob).toHaveBeenCalledWith({
|
|
type: LPR_FRAME_MIME_TYPE,
|
|
quality: LPR_FRAME_SCANNER_JPEG_QUALITY,
|
|
});
|
|
expect(fallbackCanvas.width).toBe(0);
|
|
expect(fallbackCanvas.height).toBe(0);
|
|
expect(fallbackCanvas.getContext).not.toHaveBeenCalled();
|
|
expect(fallbackDrawImage).not.toHaveBeenCalled();
|
|
expect(fallbackToBlob).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("falls back to the DOM canvas when OffscreenCanvas cannot encode a blob", async () => {
|
|
const blob = new Blob(["fallback-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const offscreenDrawImage = vi.fn();
|
|
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.getContext = vi.fn(() => ({
|
|
drawImage: offscreenDrawImage,
|
|
}));
|
|
});
|
|
const fallbackDrawImage = vi.fn();
|
|
const fallbackToBlob = vi.fn((callback) => callback(blob));
|
|
const fallbackCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fallbackDrawImage,
|
|
})),
|
|
toBlob: fallbackToBlob,
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
|
|
try {
|
|
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
|
|
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
fingerprint: `384x216:${blob.size}`,
|
|
height: 216,
|
|
width: 384,
|
|
});
|
|
expect(secondFrame).toMatchObject({
|
|
blob,
|
|
fingerprint: `384x216:${blob.size}`,
|
|
});
|
|
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
|
|
expect(offscreenDrawImage).toHaveBeenCalledTimes(1);
|
|
expect(offscreenDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
|
|
expect(fallbackCanvas.width).toBe(384);
|
|
expect(fallbackCanvas.height).toBe(216);
|
|
expect(fallbackDrawImage).toHaveBeenCalledTimes(2);
|
|
expect(fallbackDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
|
|
expect(fallbackToBlob).toHaveBeenCalledTimes(2);
|
|
expect(fallbackToBlob).toHaveBeenCalledWith(
|
|
expect.any(Function),
|
|
LPR_FRAME_MIME_TYPE,
|
|
LPR_FRAME_SCANNER_JPEG_QUALITY
|
|
);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("adds a stable visual fingerprint when canvas pixels can be sampled", async () => {
|
|
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const fingerprintDrawImage = vi.fn();
|
|
const pixels = new Uint8ClampedArray(8 * 8 * 4);
|
|
for (let index = 0; index < 8 * 8; index += 1) {
|
|
const offset = index * 4;
|
|
const value = index % 8 < 4 ? 30 : 220;
|
|
pixels[offset] = value;
|
|
pixels[offset + 1] = value;
|
|
pixels[offset + 2] = value;
|
|
pixels[offset + 3] = 255;
|
|
}
|
|
const getImageData = vi.fn(() => ({
|
|
data: pixels,
|
|
}));
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const fingerprintCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fingerprintDrawImage,
|
|
getImageData,
|
|
})),
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
|
|
|
|
expect(frame.visualFingerprint).toMatch(/^[0-9a-f]{16}$/);
|
|
expect(frame.visualFingerprint).toBe("0f0f0f0f0f0f0f0f");
|
|
expect(fingerprintCanvas.width).toBe(8);
|
|
expect(fingerprintCanvas.height).toBe(8);
|
|
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 8, 8);
|
|
expect(getImageData).toHaveBeenCalledTimes(1);
|
|
expect(getImageData).toHaveBeenCalledWith(0, 0, 8, 8);
|
|
expect(getVisualFingerprintDistance(frame.visualFingerprint, frame.visualFingerprint)).toBe(0);
|
|
expect(getVisualFingerprintDistance("0000000000000000", "000000000000000f")).toBe(4);
|
|
});
|
|
|
|
it("reuses the visual fingerprint context for repeated scene checks", async () => {
|
|
const blob = new Blob(["visual-context-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const fingerprintDrawImage = vi.fn();
|
|
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const fingerprintCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fingerprintDrawImage,
|
|
getImageData: vi.fn(() => ({
|
|
data: pixels,
|
|
})),
|
|
})),
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
|
|
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
|
|
|
|
expect(fingerprintCanvas.getContext).toHaveBeenCalledTimes(1);
|
|
expect(fingerprintDrawImage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("can skip full canvas capture before drawing a visually unchanged candidate", async () => {
|
|
const drawImage = vi.fn();
|
|
const fingerprintDrawImage = vi.fn();
|
|
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
|
|
const shouldEncode = vi.fn(() => false);
|
|
const toBlob = vi.fn();
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
const fingerprintCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fingerprintDrawImage,
|
|
getImageData: vi.fn(() => ({
|
|
data: pixels,
|
|
})),
|
|
})),
|
|
};
|
|
const video = {
|
|
videoHeight: 6,
|
|
videoWidth: 8,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
|
|
focusScale: 1,
|
|
shouldEncode,
|
|
visualFingerprintCanvas: fingerprintCanvas,
|
|
});
|
|
|
|
expect(frame).toBeNull();
|
|
expect(canvas.width).toBe(0);
|
|
expect(canvas.height).toBe(0);
|
|
expect(drawImage).not.toHaveBeenCalled();
|
|
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 0, 1, 8, 5, 0, 0, 8, 8);
|
|
expect(shouldEncode).toHaveBeenCalledWith({
|
|
height: 5,
|
|
visualFingerprint: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
width: 8,
|
|
});
|
|
expect(toBlob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("can defer the visual fingerprint until a posted frame is known to need it", async () => {
|
|
const blob = new Blob(["deferred-visual"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const fingerprintDrawImage = vi.fn();
|
|
const pixels = new Uint8ClampedArray(8 * 8 * 4);
|
|
for (let index = 0; index < 8 * 8; index += 1) {
|
|
const offset = index * 4;
|
|
const value = index % 8 < 4 ? 30 : 220;
|
|
pixels[offset] = value;
|
|
pixels[offset + 1] = value;
|
|
pixels[offset + 2] = value;
|
|
pixels[offset + 3] = 255;
|
|
}
|
|
const getImageData = vi.fn(() => ({
|
|
data: pixels,
|
|
}));
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const fingerprintCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage: fingerprintDrawImage,
|
|
getImageData,
|
|
})),
|
|
};
|
|
const shouldBuildVisualFingerprint = vi.fn(() => false);
|
|
const shouldEncode = vi.fn(() => true);
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
|
|
shouldBuildVisualFingerprint,
|
|
shouldEncode,
|
|
visualFingerprintCanvas: fingerprintCanvas,
|
|
});
|
|
|
|
expect(frame.visualFingerprint).toBeUndefined();
|
|
expect(frame.getVisualFingerprint).toEqual(expect.any(Function));
|
|
expect(shouldBuildVisualFingerprint).toHaveBeenCalledTimes(1);
|
|
expect(shouldEncode).toHaveBeenCalledWith({
|
|
height: 216,
|
|
width: 384,
|
|
});
|
|
expect(fingerprintDrawImage).not.toHaveBeenCalled();
|
|
|
|
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
|
|
expect(fingerprintDrawImage).toHaveBeenCalledWith(canvas, 0, 0, 384, 216, 0, 0, 8, 8);
|
|
expect(getImageData).toHaveBeenCalledTimes(1);
|
|
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
|
|
expect(getImageData).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("captures only the centered focus area from the visible portrait preview for multipart LPR uploads", async () => {
|
|
const blob = new Blob(["portrait-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const toBlob = vi.fn((callback) => callback(blob));
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
const video = {
|
|
clientHeight: 844,
|
|
clientWidth: 390,
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
fingerprint: `283x159:${blob.size}`,
|
|
getContentFingerprint: expect.any(Function),
|
|
height: 159,
|
|
width: 283,
|
|
});
|
|
expect(canvas.width).toBe(283);
|
|
expect(canvas.height).toBe(159);
|
|
expect(drawImage).toHaveBeenCalledWith(video, 499, 281, 283, 159, 0, 0, 283, 159);
|
|
});
|
|
|
|
it("uses supplied viewport dimensions without reading rendered video layout", async () => {
|
|
const blob = new Blob(["cached-viewport-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const getClientHeight = vi.fn(() => 0);
|
|
const getClientWidth = vi.fn(() => 0);
|
|
const video = {
|
|
get clientHeight() {
|
|
return getClientHeight();
|
|
},
|
|
get clientWidth() {
|
|
return getClientWidth();
|
|
},
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
|
|
viewportHeight: 844,
|
|
viewportWidth: 390,
|
|
});
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
fingerprint: `283x159:${blob.size}`,
|
|
height: 159,
|
|
width: 283,
|
|
});
|
|
expect(getClientHeight).not.toHaveBeenCalled();
|
|
expect(getClientWidth).not.toHaveBeenCalled();
|
|
expect(drawImage.mock.calls[0][0]).toBe(video);
|
|
expect(drawImage.mock.calls[0].slice(1)).toEqual([499, 281, 283, 159, 0, 0, 283, 159]);
|
|
});
|
|
|
|
it("captures from the scanner outline center when a focus rect is supplied", async () => {
|
|
const blob = new Blob(["focused-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const video = {
|
|
clientHeight: 844,
|
|
clientWidth: 390,
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
|
|
focusViewportRect: {
|
|
height: 234,
|
|
width: 234,
|
|
x: 78,
|
|
y: 205,
|
|
},
|
|
});
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
fingerprint: `283x159:${blob.size}`,
|
|
height: 159,
|
|
width: 283,
|
|
});
|
|
expect(drawImage).toHaveBeenCalledWith(video, 499, 195, 283, 159, 0, 0, 283, 159);
|
|
});
|
|
|
|
it("can capture the full visible preview crop for non-LPR attachment frames", async () => {
|
|
const blob = new Blob(["attachment-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const toBlob = vi.fn((callback) => callback(blob));
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
const video = {
|
|
clientHeight: 844,
|
|
clientWidth: 390,
|
|
videoHeight: 720,
|
|
videoWidth: 1280,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas, { focusCrop: false });
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
fingerprint: `266x576:${blob.size}`,
|
|
getContentFingerprint: expect.any(Function),
|
|
height: 576,
|
|
width: 266,
|
|
});
|
|
expect(canvas.width).toBe(266);
|
|
expect(canvas.height).toBe(576);
|
|
expect(drawImage).toHaveBeenCalledWith(video, 474, 0, 333, 720, 0, 0, 266, 576);
|
|
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_JPEG_QUALITY);
|
|
});
|
|
|
|
it("does not reset canvas dimensions when the target frame size is unchanged", async () => {
|
|
const blob = new Blob(["same-size-frame"], { type: LPR_FRAME_MIME_TYPE });
|
|
const drawImage = vi.fn();
|
|
const toBlob = vi.fn((callback) => callback(blob));
|
|
const widthSetter = vi.fn();
|
|
const heightSetter = vi.fn();
|
|
const canvas = {
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob,
|
|
};
|
|
Object.defineProperty(canvas, "width", {
|
|
get: () => 384,
|
|
set: widthSetter,
|
|
});
|
|
Object.defineProperty(canvas, "height", {
|
|
get: () => 216,
|
|
set: heightSetter,
|
|
});
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
fingerprint: `384x216:${blob.size}`,
|
|
getContentFingerprint: expect.any(Function),
|
|
height: 216,
|
|
width: 384,
|
|
});
|
|
expect(widthSetter).not.toHaveBeenCalled();
|
|
expect(heightSetter).not.toHaveBeenCalled();
|
|
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
|
|
});
|
|
|
|
it("does not wait for content fingerprint bytes before returning a captured frame", async () => {
|
|
const pendingArrayBuffer = new Promise(() => {});
|
|
const blob = {
|
|
size: 123,
|
|
type: LPR_FRAME_MIME_TYPE,
|
|
slice: vi.fn(() => ({
|
|
arrayBuffer: vi.fn(() => pendingArrayBuffer),
|
|
})),
|
|
};
|
|
const drawImage = vi.fn();
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: vi.fn(() => ({
|
|
drawImage,
|
|
})),
|
|
toBlob: vi.fn((callback) => callback(blob)),
|
|
};
|
|
const video = {
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
};
|
|
|
|
const frame = await captureVideoFrameBlobForLPR(video, canvas);
|
|
|
|
expect(frame).toMatchObject({
|
|
blob,
|
|
captureDurationMs: expect.any(Number),
|
|
fingerprint: "384x216:123",
|
|
getContentFingerprint: expect.any(Function),
|
|
height: 216,
|
|
width: 384,
|
|
});
|
|
expect(blob.slice).not.toHaveBeenCalled();
|
|
const contentFingerprintPromise = frame.getContentFingerprint();
|
|
expect(blob.slice).toHaveBeenCalled();
|
|
expect(frame.getContentFingerprint()).toBe(contentFingerprintPromise);
|
|
});
|
|
|
|
it("samples duplicate content fingerprint offsets only once for small blobs", async () => {
|
|
const slice = vi.fn(() => ({
|
|
arrayBuffer: vi.fn(() => Promise.resolve(new Uint8Array([1, 2, 3, 4]).buffer)),
|
|
}));
|
|
const blob = {
|
|
size: 4,
|
|
slice,
|
|
};
|
|
|
|
await expect(buildLPRFrameFingerprint(blob, 300, 225)).resolves.toMatch(/^300x225:4:[0-9a-f]{8}$/);
|
|
|
|
expect(slice).toHaveBeenCalledTimes(1);
|
|
expect(slice).toHaveBeenCalledWith(0, 4);
|
|
});
|
|
|
|
it("builds stable blob fingerprints without using capture time", async () => {
|
|
const first = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
|
|
const second = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
|
|
const changed = new Blob(["same-size-b"], { type: LPR_FRAME_MIME_TYPE });
|
|
|
|
await expect(buildLPRFrameFingerprint(second, 1024, 576)).resolves.toBe(
|
|
await buildLPRFrameFingerprint(first, 1024, 576)
|
|
);
|
|
await expect(buildLPRFrameFingerprint(changed, 1024, 576)).resolves.not.toBe(
|
|
await buildLPRFrameFingerprint(first, 1024, 576)
|
|
);
|
|
expect(buildLPRFrameFingerprintKey(first, 1024, 576)).toBe(`1024x576:${first.size}`);
|
|
});
|
|
});
|