feat(scanner): add debug instrumentation for vision pipeline timing #155
10 changed files with 470 additions and 160 deletions
|
|
@ -6,27 +6,63 @@ import ScannerScanPeek from './ScannerScanPeek.js';
|
||||||
import ScannerDisambiguation from './ScannerDisambiguation.js';
|
import ScannerDisambiguation from './ScannerDisambiguation.js';
|
||||||
import { useScannerSound } from '../../lib/use-scanner-sound.js';
|
import { useScannerSound } from '../../lib/use-scanner-sound.js';
|
||||||
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
|
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
|
||||||
|
import {
|
||||||
|
cardGuideToContainerStyle,
|
||||||
|
computeObjectCoverLayout,
|
||||||
|
videoBoundsToContainerStyle,
|
||||||
|
} from '../../lib/scanner-video-layout.js';
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 2500;
|
const TOAST_DURATION_MS = 2500;
|
||||||
|
|
||||||
function DetectionFrame({ card, videoMetrics }) {
|
function CardGuideFrame({ layout }) {
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
const style = cardGuideToContainerStyle(layout);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none scan-guide"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span className="scan-bracket scan-bracket-tl" />
|
||||||
|
<span className="scan-bracket scan-bracket-tr" />
|
||||||
|
<span className="scan-bracket scan-bracket-bl" />
|
||||||
|
<span className="scan-bracket scan-bracket-br" />
|
||||||
|
<style jsx>{`
|
||||||
|
.scan-bracket {
|
||||||
|
position: absolute;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-color: color-mix(in srgb, var(--text-primary) 70%, transparent);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.scan-bracket-tl { top: 0; left: 0; border-width: 2px 0 0 2px; }
|
||||||
|
.scan-bracket-tr { top: 0; right: 0; border-width: 2px 2px 0 0; }
|
||||||
|
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 2px 2px; }
|
||||||
|
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 2px 2px 0; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionFrame({ card, layout }) {
|
||||||
const label =
|
const label =
|
||||||
card.status === 'scanned'
|
card.status === 'scanned'
|
||||||
? `Card ${card.id} scanned`
|
? `Card ${card.id} scanned`
|
||||||
: `Card ${card.id} identified`;
|
: `Card ${card.id} identified`;
|
||||||
|
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
const style = videoBoundsToContainerStyle(card.bounds, layout);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none scan-frame"
|
className="absolute pointer-events-none scan-frame"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={{
|
style={style}
|
||||||
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
|
|
||||||
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
|
|
||||||
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
|
|
||||||
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
|
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
|
||||||
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
|
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
|
||||||
|
|
@ -118,6 +154,58 @@ function ChromeIconButton({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CameraViewport({
|
||||||
|
viewportRef,
|
||||||
|
videoRef,
|
||||||
|
isStreaming,
|
||||||
|
displayLayout,
|
||||||
|
foundCards,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
style={{ display: isStreaming ? 'block' : 'none' }}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
aria-label="Card scanner camera feed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isStreaming && displayLayout && <CardGuideFrame layout={displayLayout} />}
|
||||||
|
|
||||||
|
{isStreaming &&
|
||||||
|
displayLayout &&
|
||||||
|
foundCards.map((card) => (
|
||||||
|
<DetectionFrame key={card.id} card={card} layout={displayLayout} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isStreaming && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Starting camera…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScannerCamera({
|
export default function ScannerCamera({
|
||||||
queue,
|
queue,
|
||||||
camera,
|
camera,
|
||||||
|
|
@ -138,6 +226,8 @@ export default function ScannerCamera({
|
||||||
const toastTimerRef = useRef(null);
|
const toastTimerRef = useRef(null);
|
||||||
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
|
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
|
||||||
const galleryInputRef = useRef(null);
|
const galleryInputRef = useRef(null);
|
||||||
|
const viewportRef = useRef(null);
|
||||||
|
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoRef,
|
videoRef,
|
||||||
|
|
@ -162,6 +252,24 @@ export default function ScannerCamera({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = viewportRef.current;
|
||||||
|
if (!node) return undefined;
|
||||||
|
|
||||||
|
const syncSize = () => {
|
||||||
|
setViewportSize({
|
||||||
|
width: node.clientWidth,
|
||||||
|
height: node.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncSize();
|
||||||
|
const observer = new ResizeObserver(syncSize);
|
||||||
|
observer.observe(node);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const currentCount = queue.scannedCards?.length ?? 0;
|
const currentCount = queue.scannedCards?.length ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentCount > prevCountRef.current) {
|
if (currentCount > prevCountRef.current) {
|
||||||
|
|
@ -222,6 +330,15 @@ export default function ScannerCamera({
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
|
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
|
||||||
|
const displayLayout =
|
||||||
|
hasMetrics && viewportSize.width > 0 && viewportSize.height > 0
|
||||||
|
? computeObjectCoverLayout(
|
||||||
|
videoMetrics.width,
|
||||||
|
videoMetrics.height,
|
||||||
|
viewportSize.width,
|
||||||
|
viewportSize.height
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const showFlash = flash.flashSupported && facingMode === 'environment';
|
const showFlash = flash.flashSupported && facingMode === 'environment';
|
||||||
const scanStatus = isCheckoutOpen
|
const scanStatus = isCheckoutOpen
|
||||||
? 'Checkout open'
|
? 'Checkout open'
|
||||||
|
|
@ -248,143 +365,81 @@ export default function ScannerCamera({
|
||||||
className="absolute inset-0 md:rounded-xl overflow-hidden"
|
className="absolute inset-0 md:rounded-xl overflow-hidden"
|
||||||
style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }}
|
style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }}
|
||||||
>
|
>
|
||||||
<div
|
<CameraViewport
|
||||||
className="absolute inset-0"
|
viewportRef={viewportRef}
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
videoRef={videoRef}
|
||||||
>
|
isStreaming={isStreaming}
|
||||||
<video
|
displayLayout={displayLayout}
|
||||||
ref={videoRef}
|
foundCards={foundCards}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
/>
|
||||||
style={{ display: isStreaming ? 'block' : 'none' }}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
aria-label="Card scanner camera feed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isStreaming &&
|
|
||||||
hasMetrics &&
|
|
||||||
foundCards.map((card) => (
|
|
||||||
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!isStreaming && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
|
|
||||||
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-sm font-medium"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Starting camera…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<CameraViewport
|
||||||
className="absolute inset-0"
|
viewportRef={viewportRef}
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
videoRef={videoRef}
|
||||||
>
|
isStreaming={isStreaming}
|
||||||
<video
|
displayLayout={displayLayout}
|
||||||
ref={videoRef}
|
foundCards={foundCards}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
/>
|
||||||
style={{ display: isStreaming ? 'block' : 'none' }}
|
)}
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
aria-label="Card scanner camera feed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isStreaming &&
|
{isStreaming && (
|
||||||
hasMetrics &&
|
<div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}>
|
||||||
foundCards.map((card) => (
|
<GlassSurface
|
||||||
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
|
tint="mid"
|
||||||
))}
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
{!isStreaming && (
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
style={{ color: 'var(--text-primary)' }}
|
||||||
<div className="flex flex-col items-center gap-3">
|
>
|
||||||
<div
|
<span
|
||||||
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
|
className="w-1.5 h-1.5 rounded-full animate-pulse"
|
||||||
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
|
style={{ backgroundColor: 'var(--color-error)' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span
|
LIVE
|
||||||
className="text-sm font-medium"
|
</GlassSurface>
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Starting camera…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStreaming && (
|
{isWorkstation && isStreaming && (
|
||||||
<div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}>
|
<div className="absolute top-3 left-3 z-10 hidden md:block">
|
||||||
<GlassSurface
|
<GlassSurface
|
||||||
tint="mid"
|
tint="mid"
|
||||||
rim="subtle"
|
rim="subtle"
|
||||||
blur="mid"
|
blur="mid"
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
||||||
style={{ color: 'var(--text-primary)' }}
|
style={{ color: 'var(--text-primary)' }}
|
||||||
>
|
role="status"
|
||||||
<span
|
aria-live="polite"
|
||||||
className="w-1.5 h-1.5 rounded-full animate-pulse"
|
>
|
||||||
style={{ backgroundColor: 'var(--color-error)' }}
|
<span
|
||||||
aria-hidden="true"
|
className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
|
||||||
/>
|
style={{
|
||||||
LIVE
|
backgroundColor: autoDetectOn
|
||||||
</GlassSurface>
|
? 'var(--color-success)'
|
||||||
</div>
|
: 'var(--text-secondary)',
|
||||||
)}
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
{isWorkstation && isStreaming && (
|
/>
|
||||||
<div className="absolute top-3 left-3 z-10 hidden md:block">
|
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
|
||||||
<GlassSurface
|
</GlassSurface>
|
||||||
tint="mid"
|
|
||||||
rim="subtle"
|
|
||||||
blur="mid"
|
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: autoDetectOn
|
|
||||||
? 'var(--color-success)'
|
|
||||||
: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScannerToast
|
|
||||||
message={toast.message}
|
|
||||||
visible={toast.visible}
|
|
||||||
type={toast.type}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={isWorkstation ? 'md:hidden' : undefined}>
|
|
||||||
<ScannerScanPeek
|
|
||||||
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
|
|
||||||
card={latestPeekCard}
|
|
||||||
onOpenCheckout={onOpenCheckout}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScannerToast
|
||||||
|
message={toast.message}
|
||||||
|
visible={toast.visible}
|
||||||
|
type={toast.type}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={isWorkstation ? 'md:hidden' : undefined}>
|
||||||
|
<ScannerScanPeek
|
||||||
|
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
|
||||||
|
card={latestPeekCard}
|
||||||
|
onOpenCheckout={onOpenCheckout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`}
|
className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ const LIMITER_CONFIG = {
|
||||||
upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' },
|
upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' },
|
||||||
generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' },
|
generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' },
|
||||||
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
||||||
scan: { limit: 5, durationSec: 60, prefix: 'deckhearth:scan' },
|
// One camera verify may escalate L0→L2; vision path is the expensive step.
|
||||||
|
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached = null;
|
let cached = null;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,16 @@ export const MIN_STABLE_COUNT_FOR_VERIFY = 3;
|
||||||
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800;
|
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800;
|
||||||
|
|
||||||
/** Delay after camera start before detection loops begin. */
|
/** Delay after camera start before detection loops begin. */
|
||||||
export const DETECTION_START_DELAY_MS = 1000;
|
export const DETECTION_START_DELAY_MS = 300;
|
||||||
|
|
||||||
|
/** Max verification attempts per tracked card before requiring reset. */
|
||||||
|
export const MAX_SCAN_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/** Ms before a negative tracker can retry without large movement. */
|
||||||
|
export const NEGATIVE_RETRY_MS = 5000;
|
||||||
|
|
||||||
|
/** Terminal tracker statuses that should not absorb new shape detections. */
|
||||||
|
export const TERMINAL_TRACKER_STATUSES = new Set(['confirmed', 'scanned']);
|
||||||
|
|
||||||
/** Tracked cards eligible for one-shot server verification. */
|
/** Tracked cards eligible for one-shot server verification. */
|
||||||
export function selectCardsReadyForVerification(
|
export function selectCardsReadyForVerification(
|
||||||
|
|
@ -32,7 +41,7 @@ export function selectCardsReadyForVerification(
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
{
|
{
|
||||||
minStableCount = MIN_STABLE_COUNT_FOR_VERIFY,
|
minStableCount = MIN_STABLE_COUNT_FOR_VERIFY,
|
||||||
maxScanAttempts = 1,
|
maxScanAttempts = MAX_SCAN_ATTEMPTS,
|
||||||
minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY,
|
minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
|
|
@ -198,7 +207,7 @@ function scoreCardCandidate(edges, width, height, bbox) {
|
||||||
|
|
||||||
function findCardCandidates(edges, width, height) {
|
function findCardCandidates(edges, width, height) {
|
||||||
const minCardWidth = Math.floor(width * 0.2);
|
const minCardWidth = Math.floor(width * 0.2);
|
||||||
const maxCardWidth = Math.floor(width * 0.6);
|
const maxCardWidth = Math.floor(width * 0.85);
|
||||||
const minCardHeight = Math.floor(height * 0.25);
|
const minCardHeight = Math.floor(height * 0.25);
|
||||||
const maxCardHeight = Math.floor(height * 0.7);
|
const maxCardHeight = Math.floor(height * 0.7);
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
|
@ -303,6 +312,7 @@ export function mergeDetectedShapesIntoTrackedCards(
|
||||||
let matchedCard = null;
|
let matchedCard = null;
|
||||||
|
|
||||||
for (const card of updatedCards) {
|
for (const card of updatedCards) {
|
||||||
|
if (TERMINAL_TRACKER_STATUSES.has(card.status)) continue;
|
||||||
if (rectanglesOverlap(shape, card.bounds, 0.4)) {
|
if (rectanglesOverlap(shape, card.bounds, 0.4)) {
|
||||||
matchedCard = card;
|
matchedCard = card;
|
||||||
break;
|
break;
|
||||||
|
|
@ -310,6 +320,7 @@ export function mergeDetectedShapesIntoTrackedCards(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedCard) {
|
if (matchedCard) {
|
||||||
|
const previousBounds = matchedCard.bounds;
|
||||||
matchedCard.bounds = {
|
matchedCard.bounds = {
|
||||||
x: shape.x,
|
x: shape.x,
|
||||||
y: shape.y,
|
y: shape.y,
|
||||||
|
|
@ -322,13 +333,17 @@ export function mergeDetectedShapesIntoTrackedCards(
|
||||||
matchedCard.lastSeen = now;
|
matchedCard.lastSeen = now;
|
||||||
matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10);
|
matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10);
|
||||||
|
|
||||||
const positionChange =
|
if (matchedCard.status === 'negative') {
|
||||||
Math.abs(matchedCard.bounds.x - shape.x) + Math.abs(matchedCard.bounds.y - shape.y);
|
const negativeSince = matchedCard.negativeAt ?? matchedCard.lastSeen;
|
||||||
if (positionChange > 50 && matchedCard.status === 'negative') {
|
const positionChange =
|
||||||
console.log(`🔄 Card ${matchedCard.id} moved significantly, allowing re-scan`);
|
Math.abs(previousBounds.x - shape.x) + Math.abs(previousBounds.y - shape.y);
|
||||||
matchedCard.status = 'detecting';
|
if (positionChange > 25 || now - negativeSince > NEGATIVE_RETRY_MS) {
|
||||||
matchedCard.scanAttempts = 0;
|
console.log(`🔄 Card ${matchedCard.id} ready for re-scan`);
|
||||||
matchedCard.stableCount = 1;
|
matchedCard.status = 'detecting';
|
||||||
|
matchedCard.scanAttempts = 0;
|
||||||
|
matchedCard.stableCount = Math.max(matchedCard.stableCount, MIN_STABLE_COUNT_FOR_VERIFY);
|
||||||
|
matchedCard.negativeAt = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newCard = {
|
const newCard = {
|
||||||
|
|
|
||||||
98
lib/scanner-video-layout.js
Normal file
98
lib/scanner-video-layout.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/** Trading card aspect ratio (width / height). */
|
||||||
|
export const CARD_ASPECT_RATIO = 5 / 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout math for a video element using CSS object-cover inside a container.
|
||||||
|
*/
|
||||||
|
export function computeObjectCoverLayout(videoWidth, videoHeight, containerWidth, containerHeight) {
|
||||||
|
if (!videoWidth || !videoHeight || !containerWidth || !containerHeight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoAspect = videoWidth / videoHeight;
|
||||||
|
const containerAspect = containerWidth / containerHeight;
|
||||||
|
|
||||||
|
let renderedWidth;
|
||||||
|
let renderedHeight;
|
||||||
|
let offsetX;
|
||||||
|
let offsetY;
|
||||||
|
|
||||||
|
if (videoAspect > containerAspect) {
|
||||||
|
renderedHeight = containerHeight;
|
||||||
|
renderedWidth = containerHeight * videoAspect;
|
||||||
|
offsetX = (containerWidth - renderedWidth) / 2;
|
||||||
|
offsetY = 0;
|
||||||
|
} else {
|
||||||
|
renderedWidth = containerWidth;
|
||||||
|
renderedHeight = containerWidth / videoAspect;
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = (containerHeight - renderedHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
renderedWidth,
|
||||||
|
renderedHeight,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map video-pixel bounds to percentage styles within an object-cover container. */
|
||||||
|
export function videoBoundsToContainerStyle(bounds, layout) {
|
||||||
|
const {
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
renderedWidth,
|
||||||
|
renderedHeight,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = layout;
|
||||||
|
|
||||||
|
const left = offsetX + (bounds.x / videoWidth) * renderedWidth;
|
||||||
|
const top = offsetY + (bounds.y / videoHeight) * renderedHeight;
|
||||||
|
const width = (bounds.width / videoWidth) * renderedWidth;
|
||||||
|
const height = (bounds.height / videoHeight) * renderedHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${(left / containerWidth) * 100}%`,
|
||||||
|
top: `${(top / containerHeight) * 100}%`,
|
||||||
|
width: `${(width / containerWidth) * 100}%`,
|
||||||
|
height: `${(height / containerHeight) * 100}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered card guide frame in container pixel coordinates. */
|
||||||
|
export function computeCardGuideBounds(containerWidth, containerHeight) {
|
||||||
|
let width = containerWidth * 0.78;
|
||||||
|
let height = width / CARD_ASPECT_RATIO;
|
||||||
|
|
||||||
|
const maxHeight = containerHeight * 0.72;
|
||||||
|
if (height > maxHeight) {
|
||||||
|
height = maxHeight;
|
||||||
|
width = height * CARD_ASPECT_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: (containerWidth - width) / 2,
|
||||||
|
top: (containerHeight - height) / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card guide as percentage styles for an object-cover container. */
|
||||||
|
export function cardGuideToContainerStyle(layout) {
|
||||||
|
const guide = computeCardGuideBounds(layout.containerWidth, layout.containerHeight);
|
||||||
|
return {
|
||||||
|
left: `${(guide.left / layout.containerWidth) * 100}%`,
|
||||||
|
top: `${(guide.top / layout.containerHeight) * 100}%`,
|
||||||
|
width: `${(guide.width / layout.containerWidth) * 100}%`,
|
||||||
|
height: `${(guide.height / layout.containerHeight) * 100}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -168,6 +168,12 @@ export function useCameraScanner({
|
||||||
setTrackedCards([]);
|
setTrackedCards([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeTrackedCard = useCallback((cardId) => {
|
||||||
|
const next = trackedCardsRef.current.filter((card) => card.id !== cardId);
|
||||||
|
trackedCardsRef.current = next;
|
||||||
|
setTrackedCards(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const startDetection = () => {
|
const startDetection = () => {
|
||||||
if (detectionIntervalRef.current || !isStreamingRef.current) return;
|
if (detectionIntervalRef.current || !isStreamingRef.current) return;
|
||||||
|
|
||||||
|
|
@ -312,6 +318,12 @@ export function useCameraScanner({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming) return undefined;
|
||||||
|
import('./ocr-worker.js').catch(() => {});
|
||||||
|
return undefined;
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
stopCamera();
|
stopCamera();
|
||||||
|
|
@ -359,6 +371,7 @@ export function useCameraScanner({
|
||||||
videoMetrics,
|
videoMetrics,
|
||||||
startCamera,
|
startCamera,
|
||||||
stopCamera,
|
stopCamera,
|
||||||
|
removeTrackedCard,
|
||||||
streamRef,
|
streamRef,
|
||||||
facingMode: activeFacingMode,
|
facingMode: activeFacingMode,
|
||||||
switchFacingMode,
|
switchFacingMode,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function readFileToImageData(file) {
|
||||||
export function useScannerIdentification({
|
export function useScannerIdentification({
|
||||||
onCardScanned,
|
onCardScanned,
|
||||||
onError,
|
onError,
|
||||||
|
onTrackerComplete,
|
||||||
videoRef,
|
videoRef,
|
||||||
canvasRef,
|
canvasRef,
|
||||||
onVerifyCardRef,
|
onVerifyCardRef,
|
||||||
|
|
@ -60,6 +61,17 @@ export function useScannerIdentification({
|
||||||
const lastErrorAtRef = useRef(0);
|
const lastErrorAtRef = useRef(0);
|
||||||
const disambiguationRefineRef = useRef(null);
|
const disambiguationRefineRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (verificationPausedRef) {
|
||||||
|
verificationPausedRef.current = Boolean(disambiguation);
|
||||||
|
}
|
||||||
|
}, [disambiguation, verificationPausedRef]);
|
||||||
|
|
||||||
|
const finishTrackedCard = (cardTracker) => {
|
||||||
|
if (!cardTracker?.id) return;
|
||||||
|
onTrackerComplete?.(cardTracker.id);
|
||||||
|
};
|
||||||
|
|
||||||
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
||||||
cardTracker.status = 'scanned';
|
cardTracker.status = 'scanned';
|
||||||
|
|
||||||
|
|
@ -79,6 +91,7 @@ export function useScannerIdentification({
|
||||||
}
|
}
|
||||||
|
|
||||||
onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta }));
|
onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta }));
|
||||||
|
finishTrackedCard(cardTracker);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScanNotice = (message) => {
|
const showScanNotice = (message) => {
|
||||||
|
|
@ -87,7 +100,10 @@ export function useScannerIdentification({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewSubmitted = (cardTracker, message) => {
|
const handleReviewSubmitted = (cardTracker, message) => {
|
||||||
if (cardTracker) cardTracker.status = 'confirmed';
|
if (cardTracker) {
|
||||||
|
cardTracker.status = 'confirmed';
|
||||||
|
finishTrackedCard(cardTracker);
|
||||||
|
}
|
||||||
setDisambiguation(null);
|
setDisambiguation(null);
|
||||||
disambiguationRefineRef.current = null;
|
disambiguationRefineRef.current = null;
|
||||||
showScanNotice(message);
|
showScanNotice(message);
|
||||||
|
|
@ -102,6 +118,11 @@ export function useScannerIdentification({
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelDisambiguation = () => {
|
const cancelDisambiguation = () => {
|
||||||
|
if (disambiguation?.cardTracker) {
|
||||||
|
disambiguation.cardTracker.status = 'detecting';
|
||||||
|
disambiguation.cardTracker.scanAttempts = 0;
|
||||||
|
disambiguation.cardTracker.negativeAt = undefined;
|
||||||
|
}
|
||||||
setDisambiguation(null);
|
setDisambiguation(null);
|
||||||
disambiguationRefineRef.current = null;
|
disambiguationRefineRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
@ -115,13 +136,13 @@ export function useScannerIdentification({
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => {
|
const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => {
|
||||||
cardTracker.status = outcome.cardStatus;
|
|
||||||
|
|
||||||
switch (outcome.type) {
|
switch (outcome.type) {
|
||||||
case 'emit':
|
case 'emit':
|
||||||
|
cardTracker.status = outcome.cardStatus;
|
||||||
await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta);
|
await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta);
|
||||||
break;
|
break;
|
||||||
case 'disambiguation':
|
case 'disambiguation':
|
||||||
|
cardTracker.status = 'verifying';
|
||||||
setDisambiguation({
|
setDisambiguation({
|
||||||
cardTracker,
|
cardTracker,
|
||||||
imageData,
|
imageData,
|
||||||
|
|
@ -133,9 +154,13 @@ export function useScannerIdentification({
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'notice':
|
case 'notice':
|
||||||
|
cardTracker.status = outcome.cardStatus;
|
||||||
showScanNotice(outcome.message);
|
showScanNotice(outcome.message);
|
||||||
|
finishTrackedCard(cardTracker);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
|
cardTracker.status = outcome.cardStatus;
|
||||||
|
cardTracker.negativeAt = Date.now();
|
||||||
reportScannerError(outcome.message);
|
reportScannerError(outcome.message);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -297,16 +322,21 @@ export function useScannerIdentification({
|
||||||
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
|
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
|
||||||
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
|
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
|
cardTracker.negativeAt = Date.now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identification.handled && identification.outcome) {
|
if (identification.handled && identification.outcome) {
|
||||||
cardTracker.status = 'confirmed';
|
cardTracker.status = 'confirmed';
|
||||||
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
|
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
|
||||||
|
} else {
|
||||||
|
cardTracker.status = 'negative';
|
||||||
|
cardTracker.negativeAt = Date.now();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
|
cardTracker.negativeAt = Date.now();
|
||||||
reportScannerError(error.message || 'Scan failed');
|
reportScannerError(error.message || 'Scan failed');
|
||||||
} finally {
|
} finally {
|
||||||
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
|
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
|
||||||
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
|
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
|
||||||
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js';
|
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js';
|
||||||
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
import { logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||||||
|
|
@ -63,12 +62,6 @@ export default async function handler(req, res) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { allowed, reset } = await checkScanRateLimit(req, user.userId);
|
|
||||||
if (!allowed) {
|
|
||||||
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
|
||||||
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedding = await embedCardImage(imageData);
|
const embedding = await embedCardImage(imageData);
|
||||||
const matchResult = await matchVisualInCatalog({ embedding, game: game || null });
|
const matchResult = await matchVisualInCatalog({ embedding, game: game || null });
|
||||||
const latencyMs = Date.now() - startedAt;
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export default function Scanner() {
|
||||||
const identification = useScannerIdentification({
|
const identification = useScannerIdentification({
|
||||||
onCardScanned: queue.handleCardScanned,
|
onCardScanned: queue.handleCardScanned,
|
||||||
onError: (msg) => console.error('Identification error:', msg),
|
onError: (msg) => console.error('Identification error:', msg),
|
||||||
|
onTrackerComplete: camera.removeTrackedCard,
|
||||||
videoRef: camera.videoRef,
|
videoRef: camera.videoRef,
|
||||||
canvasRef: camera.canvasRef,
|
canvasRef: camera.canvasRef,
|
||||||
onVerifyCardRef,
|
onVerifyCardRef,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
convertToVideoCoordinates,
|
convertToVideoCoordinates,
|
||||||
|
MAX_SCAN_ATTEMPTS,
|
||||||
mergeDetectedShapesIntoTrackedCards,
|
mergeDetectedShapesIntoTrackedCards,
|
||||||
|
NEGATIVE_RETRY_MS,
|
||||||
rectanglesOverlap,
|
rectanglesOverlap,
|
||||||
refineCardCornersFromEdges,
|
refineCardCornersFromEdges,
|
||||||
selectCardsReadyForVerification,
|
selectCardsReadyForVerification,
|
||||||
|
|
@ -126,9 +128,22 @@ describe('selectCardsReadyForVerification', () => {
|
||||||
expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
|
expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes cards that already attempted verification', () => {
|
it('excludes cards that exhausted verification attempts', () => {
|
||||||
const now = 10_000;
|
const now = 10_000;
|
||||||
const retried = {
|
const retried = {
|
||||||
|
id: 1,
|
||||||
|
status: 'detecting',
|
||||||
|
stableCount: 8,
|
||||||
|
scanAttempts: MAX_SCAN_ATTEMPTS,
|
||||||
|
firstSeen: now - 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows retry after a failed attempt when under the attempt cap', () => {
|
||||||
|
const now = 10_000;
|
||||||
|
const retrying = {
|
||||||
id: 1,
|
id: 1,
|
||||||
status: 'detecting',
|
status: 'detecting',
|
||||||
stableCount: 8,
|
stableCount: 8,
|
||||||
|
|
@ -136,7 +151,47 @@ describe('selectCardsReadyForVerification', () => {
|
||||||
firstSeen: now - 5000,
|
firstSeen: now - 5000,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
|
expect(selectCardsReadyForVerification([retrying], now)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new tracker when a finished card overlaps a fresh detection', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const finished = {
|
||||||
|
id: 1,
|
||||||
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
||||||
|
status: 'scanned',
|
||||||
|
firstSeen: now - 2000,
|
||||||
|
lastSeen: now - 100,
|
||||||
|
stableCount: 5,
|
||||||
|
scanAttempts: 1,
|
||||||
|
};
|
||||||
|
const shape = { x: 12, y: 22, width: 100, height: 140, score: 80 };
|
||||||
|
|
||||||
|
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([finished], [shape], 2, now);
|
||||||
|
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
expect(cards.some((card) => card.id === 2 && card.status === 'detecting')).toBe(true);
|
||||||
|
expect(nextCardId).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets negative trackers after the retry window', () => {
|
||||||
|
const now = 10_000;
|
||||||
|
const negative = {
|
||||||
|
id: 1,
|
||||||
|
bounds: { x: 10, y: 20, width: 100, height: 140 },
|
||||||
|
status: 'negative',
|
||||||
|
negativeAt: now - NEGATIVE_RETRY_MS - 1,
|
||||||
|
firstSeen: now - 8000,
|
||||||
|
lastSeen: now - 100,
|
||||||
|
stableCount: 2,
|
||||||
|
scanAttempts: 1,
|
||||||
|
};
|
||||||
|
const shape = { x: 10, y: 20, width: 100, height: 140, score: 80 };
|
||||||
|
|
||||||
|
const { cards } = mergeDetectedShapesIntoTrackedCards([negative], [shape], 2, now);
|
||||||
|
|
||||||
|
expect(cards[0].status).toBe('detecting');
|
||||||
|
expect(cards[0].scanAttempts).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
49
test/lib/scanner-video-layout.test.js
Normal file
49
test/lib/scanner-video-layout.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
cardGuideToContainerStyle,
|
||||||
|
computeCardGuideBounds,
|
||||||
|
computeObjectCoverLayout,
|
||||||
|
videoBoundsToContainerStyle,
|
||||||
|
} from '../../lib/scanner-video-layout.js';
|
||||||
|
|
||||||
|
describe('computeObjectCoverLayout', () => {
|
||||||
|
it('computes horizontal crop when video is wider than container', () => {
|
||||||
|
const layout = computeObjectCoverLayout(1280, 720, 390, 844);
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout.renderedHeight).toBe(844);
|
||||||
|
expect(layout.renderedWidth).toBeGreaterThan(390);
|
||||||
|
expect(layout.offsetX).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('videoBoundsToContainerStyle', () => {
|
||||||
|
it('maps full-frame video bounds to full container percentages', () => {
|
||||||
|
const layout = computeObjectCoverLayout(1000, 1000, 500, 500);
|
||||||
|
const style = videoBoundsToContainerStyle(
|
||||||
|
{ x: 0, y: 0, width: 1000, height: 1000 },
|
||||||
|
layout
|
||||||
|
);
|
||||||
|
expect(style).toEqual({
|
||||||
|
left: '0%',
|
||||||
|
top: '0%',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('card guide layout', () => {
|
||||||
|
it('centers a 5:7 guide within the container', () => {
|
||||||
|
const guide = computeCardGuideBounds(400, 800);
|
||||||
|
expect(guide.width / guide.height).toBeCloseTo(5 / 7, 5);
|
||||||
|
expect(guide.left).toBeGreaterThan(0);
|
||||||
|
expect(guide.top).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns percentage styles for the guide overlay', () => {
|
||||||
|
const layout = computeObjectCoverLayout(1280, 720, 390, 844);
|
||||||
|
const style = cardGuideToContainerStyle(layout);
|
||||||
|
expect(style.left).toMatch(/%$/);
|
||||||
|
expect(style.width).toMatch(/%$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue