diff --git a/packages/api/src/services/results/recognizeScore.ts b/packages/api/src/services/results/recognizeScore.ts
index 7b3124ee..2ef65528 100644
--- a/packages/api/src/services/results/recognizeScore.ts
+++ b/packages/api/src/services/results/recognizeScore.ts
@@ -73,8 +73,8 @@ export const recognizeScore = async (
type: 'input_text',
text:
mix === 'Phoenix'
- ? `Extract the vertically lined up white numbers from the photo according to the provided schema. One number per line, some numbers may have leading zeroes. All zeroes have a dot in the middle.`
- : `Extract the vertically lined up white numbers from the photo according to the provided schema. One number per line, some numbers may have leading zeroes.`,
+ ? `Extract the vertically lined up numbers from the processed photo according to the provided schema. One number per line, some numbers may have leading zeroes. All zeroes have a dot in the middle.`
+ : `Extract the vertically lined up numbers from the processed photo according to the provided schema. One number per line, some numbers may have leading zeroes.`,
},
{
type: 'input_image',
diff --git a/packages/web/src/features/leaderboards/AddResult.tsx b/packages/web/src/features/leaderboards/components/add-result/AddResult.tsx
similarity index 94%
rename from packages/web/src/features/leaderboards/AddResult.tsx
rename to packages/web/src/features/leaderboards/components/add-result/AddResult.tsx
index 381d56b3..b8697e88 100644
--- a/packages/web/src/features/leaderboards/AddResult.tsx
+++ b/packages/web/src/features/leaderboards/components/add-result/AddResult.tsx
@@ -17,7 +17,7 @@ import { FaExclamationCircle } from 'react-icons/fa';
import { IoIosWarning } from 'react-icons/io';
import { useNavigate, useParams } from 'react-router-dom';
-import css from './components/add-result/add-result.module.scss';
+import css from './add-result.module.scss';
import { useConfirmationPopup } from 'components/ConfirmationPopup/useConfirmationPopup';
import Loader from 'components/Loader/Loader';
@@ -29,10 +29,11 @@ import { useUser } from 'hooks/useUser';
import { useLanguage } from 'utils/context/translation';
import { api } from 'utils/trpc';
-import { ScreenshotRecognition } from './components/add-result/ScreenshotRecognition';
-import { compressDataUrl } from './components/add-result/compressFile';
-import { useProcessedImage } from './components/add-result/useProcessedImage';
-import { useSingleChartQuery } from './hooks/useSingleChartQuery';
+import { useSingleChartQuery } from '../../hooks/useSingleChartQuery';
+import { ImageDataPreview } from './ImageDataPreview';
+import { ScreenshotRecognition } from './ScreenshotRecognition';
+import { compressImageData } from './imageUtils';
+import { useProcessedImage } from './useProcessedImage';
const GRADE_OPTIONS = [
{ value: 'SSS', label: 'SSS' },
@@ -196,7 +197,7 @@ const AddResult = () => {
setError(null);
try {
- const compressedScreenshot = await compressDataUrl(processedImage.squareDataUrl);
+ const compressedScreenshot = await compressImageData(processedImage.imageData);
const data = {
pass: rawData.pass,
@@ -255,9 +256,8 @@ const AddResult = () => {
{processedImage && (
-
)}
@@ -329,7 +329,8 @@ const AddResult = () => {
<>
{
diff --git a/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx b/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx
new file mode 100644
index 00000000..8923ff89
--- /dev/null
+++ b/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from 'react';
+
+interface ImageDataPreviewProps {
+ imageData: ImageData;
+ style?: React.CSSProperties;
+}
+
+export const ImageDataPreview = ({ imageData, style }: ImageDataPreviewProps) => {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ canvas.width = imageData.width;
+ canvas.height = imageData.height;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.putImageData(imageData, 0, 0);
+ }
+ }, [imageData]);
+
+ return ;
+};
diff --git a/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx b/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx
index 2c11ece0..dc258e76 100644
--- a/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx
+++ b/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx
@@ -3,12 +3,17 @@ import { AreaSelector, type IArea } from '@bmunozg/react-image-area';
import { Alert, Button, Group, Popover, Stack, Text } from '@mantine/core';
import { useMutation } from '@tanstack/react-query';
import imageCompression from 'browser-image-compression';
-import { useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useLanguage } from 'utils/context/translation';
import { api } from 'utils/trpc';
-import { cropAreaToDataUrl } from './cropImage';
+import {
+ applyProcessing,
+ imageDataToCanvas,
+ prepareImageForRecognition,
+ shrinkAreaToBrightness,
+} from './imageUtils';
export interface RecognizedScore {
perfect: number;
@@ -21,7 +26,8 @@ export interface RecognizedScore {
}
interface ScreenshotRecognitionProps {
- squareDataUrl: string;
+ imageData: ImageData;
+ naturalSize: number;
date?: Date | null;
showDate?: boolean;
onScoreRecognized?: (score: RecognizedScore) => void;
@@ -29,19 +35,92 @@ interface ScreenshotRecognitionProps {
}
export const ScreenshotRecognition = ({
- squareDataUrl,
+ imageData: originalImageData,
+ naturalSize,
date,
mix,
showDate,
onScoreRecognized,
}: ScreenshotRecognitionProps) => {
+ const canvasRef = useRef(null);
+ const imageDataRef = useRef();
+
+ const drawToCanvas = (data: ImageData) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ canvas.width = data.width;
+ canvas.height = data.height;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.putImageData(data, 0, 0);
+ }
+ };
+
+ useEffect(() => {
+ imageDataRef.current = structuredClone(originalImageData);
+ drawToCanvas(imageDataRef.current);
+ setAreas([]);
+ }, [originalImageData]);
+
const lang = useLanguage();
const [error, setError] = useState(null);
const [areas, setAreas] = useState([]);
const [recognizedNumbers, setRecognizedNumbers] = useState(null);
- // const [croppedPreview, setCroppedPreview] = useState(null);
- const imgRef = useRef(null);
+ const areaSelectorRef = useRef(null);
const [isCropping, setIsCropping] = useState(false);
+ const [debugImage, setDebugImage] = useState();
+ const isInteractingRef = useRef(false);
+ const areasRef = useRef(areas);
+ areasRef.current = areas;
+
+ const handleAreasChange = (newAreas: IArea[]) => {
+ isInteractingRef.current = true;
+ setAreas(newAreas);
+ };
+
+ const triggerShrink = useCallback(() => {
+ if (!isInteractingRef.current) return;
+ isInteractingRef.current = false;
+
+ const currentAreas = areasRef.current;
+
+ if (currentAreas.length === 0 || !canvasRef.current) return;
+
+ const area = currentAreas[0];
+ const displayedSize = canvasRef.current.clientWidth;
+
+ // crop to get a new area
+ const shrunkArea = shrinkAreaToBrightness(originalImageData, naturalSize, area, displayedSize);
+
+ if (imageDataRef.current) {
+ // apply processing filter to cropped area
+ const scale = naturalSize / displayedSize;
+ applyProcessing(
+ imageDataRef.current,
+ originalImageData,
+ Math.round(shrunkArea.x * scale),
+ Math.round(shrunkArea.y * scale),
+ Math.round(shrunkArea.width * scale),
+ Math.round(shrunkArea.height * scale)
+ );
+ drawToCanvas(imageDataRef.current);
+ }
+
+ setAreas([shrunkArea]);
+ }, [naturalSize, originalImageData]);
+
+ useEffect(() => {
+ const handlePointerUp = () => {
+ // trigger shrink after AreaSelector finishes event handling
+ setTimeout(triggerShrink, 0);
+ };
+
+ document.addEventListener('pointerup', handlePointerUp, { capture: true });
+
+ return () => {
+ document.removeEventListener('pointerup', handlePointerUp, { capture: true });
+ };
+ }, [triggerShrink]);
const recognizeScoreMutation = useMutation(api.results.recognizeScoreMutation.mutationOptions());
@@ -49,20 +128,35 @@ export const ScreenshotRecognition = ({
// if (areas.length === 0 || !imgRef.current) return undefined;
// const area = areas[0];
// const displayedSize = imgRef.current.clientWidth;
- // cropAreaToDataUrl(squareDataUrl, area, displayedSize).then((t) => setCroppedPreview(t));
+ // prepareImageForRecognition(squareDataUrl, area, displayedSize).then((t) =>
+ // setCroppedPreview(t)
+ // );
// }, [squareDataUrl, areas]);
const handleRecognizeScore = async () => {
- if (areas.length === 0 || !imgRef.current) return;
+ if (areas.length === 0 || !canvasRef.current || !imageDataRef.current) return;
try {
setError(null);
setIsCropping(true);
- const displayedSize = imgRef.current.clientWidth;
- const croppedImage = await cropAreaToDataUrl(squareDataUrl, areas[0], displayedSize);
+ const displayedSize = canvasRef.current.clientWidth;
+
+ // Get processed ImageData (synchronous)
+ const croppedImageData = prepareImageForRecognition(
+ originalImageData,
+ naturalSize,
+ areas[0],
+ displayedSize
+ );
- // Compress the cropped image before sending for recognition
- const croppedFile = await imageCompression.getFilefromDataUrl(croppedImage, 'cropped.png');
+ // Convert to canvas and compress for API
+ const croppedCanvas = imageDataToCanvas(croppedImageData);
+ const croppedFile = await imageCompression.canvasToFile(
+ croppedCanvas,
+ 'image/png',
+ 'cropped.png',
+ new Date().getTime()
+ );
const compressedFile = await imageCompression(croppedFile, {
maxWidthOrHeight: 360,
maxSizeMB: 0.03,
@@ -70,6 +164,8 @@ export const ScreenshotRecognition = ({
});
const compressedImage = await imageCompression.getDataUrlFromFile(compressedFile);
+ setDebugImage(compressedImage);
+
const numbers = await recognizeScoreMutation.mutateAsync({
image: compressedImage,
mix,
@@ -107,7 +203,7 @@ export const ScreenshotRecognition = ({
};
return (
-
+
{showDate && (
{lang.DATE_TAKEN}: {date ? date.toLocaleString() : lang.UNKNOWN}
@@ -116,19 +212,14 @@ export const ScreenshotRecognition = ({
-
+
{/* {croppedPreview && } */}
@@ -173,46 +264,63 @@ export const ScreenshotRecognition = ({
{recognizedNumbers && (
-
-
- Perfect:
-
-
- {recognizedNumbers[0] >= 0 ? recognizedNumbers[0] : '?'}
-
-
- Great:
-
-
- {recognizedNumbers[1] >= 0 ? recognizedNumbers[1] : '?'}
-
-
- Good:
-
-
- {recognizedNumbers[2] >= 0 ? recognizedNumbers[2] : '?'}
-
-
- Bad:
-
-
- {recognizedNumbers[3] >= 0 ? recognizedNumbers[3] : '?'}
-
-
- Miss:
-
-
- {recognizedNumbers[4] >= 0 ? recognizedNumbers[4] : '?'}
-
- Combo:
- {recognizedNumbers[5] >= 0 ? recognizedNumbers[5] : '?'}
-
- Score:
-
-
- {recognizedNumbers[6] >= 0 ? recognizedNumbers[6] : '?'}
-
-
+
+
+
+ Perfect:
+
+
+ {recognizedNumbers[0] >= 0 ? recognizedNumbers[0] : '?'}
+
+
+ Great:
+
+
+ {recognizedNumbers[1] >= 0 ? recognizedNumbers[1] : '?'}
+
+
+ Good:
+
+
+ {recognizedNumbers[2] >= 0 ? recognizedNumbers[2] : '?'}
+
+
+ Bad:
+
+
+ {recognizedNumbers[3] >= 0 ? recognizedNumbers[3] : '?'}
+
+
+ Miss:
+
+
+ {recognizedNumbers[4] >= 0 ? recognizedNumbers[4] : '?'}
+
+ Combo:
+
+ {recognizedNumbers[5] >= 0 ? recognizedNumbers[5] : '?'}
+
+
+ Score:
+
+
+ {recognizedNumbers[6] >= 0 ? recognizedNumbers[6] : '?'}
+
+
+ {debugImage && (
+
+ )}
+
{lang.FILL_FORM}
diff --git a/packages/web/src/features/leaderboards/components/add-result/compressFile.ts b/packages/web/src/features/leaderboards/components/add-result/compressFile.ts
deleted file mode 100644
index b46be177..00000000
--- a/packages/web/src/features/leaderboards/components/add-result/compressFile.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import imageCompression from 'browser-image-compression';
-
-export const compressFile = (file: File) => {
- return imageCompression(file, { maxWidthOrHeight: 1400, maxSizeMB: 0.1, initialQuality: 0.7 });
-};
-
-export const compressDataUrl = async (dataUrl: string): Promise => {
- const file = await imageCompression.getFilefromDataUrl(dataUrl, 'image.jpg');
- const compressed = await imageCompression(file, {
- maxWidthOrHeight: 1200,
- maxSizeMB: 0.1,
- initialQuality: 0.8,
- });
- return imageCompression.getDataUrlFromFile(compressed);
-};
diff --git a/packages/web/src/features/leaderboards/components/add-result/cropImage.ts b/packages/web/src/features/leaderboards/components/add-result/cropImage.ts
deleted file mode 100644
index cde6eef0..00000000
--- a/packages/web/src/features/leaderboards/components/add-result/cropImage.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { IArea } from '@bmunozg/react-image-area';
-
-/**
- * Crops a selected area from a square image and returns a base64 data URL.
- * @param displayedSize - The size of the square image as displayed on screen
- */
-export const cropAreaToDataUrl = (
- imageSrc: string,
- area: IArea,
- displayedSize: number
-): Promise => {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
-
- if (!ctx) {
- reject(new Error('Could not get canvas context'));
- return;
- }
-
- // Scale coordinates from displayed size to natural size
- const scale = img.naturalWidth / displayedSize;
-
- const scaledX = area.x * scale;
- const scaledY = area.y * scale;
- const scaledWidth = area.width * scale;
- const scaledHeight = area.height * scale;
-
- canvas.width = scaledWidth;
- canvas.height = scaledHeight;
-
- ctx.drawImage(
- img,
- scaledX,
- scaledY,
- scaledWidth,
- scaledHeight,
- 0,
- 0,
- scaledWidth,
- scaledHeight
- );
-
- resolve(canvas.toDataURL('image/jpeg', 0.95));
- };
-
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = imageSrc;
- });
-};
diff --git a/packages/web/src/features/leaderboards/components/add-result/getDate.ts b/packages/web/src/features/leaderboards/components/add-result/getDate.ts
deleted file mode 100644
index 5c22f473..00000000
--- a/packages/web/src/features/leaderboards/components/add-result/getDate.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { parse } from 'exif-date';
-import * as ExifReader from 'exifreader';
-
-export const getDateFromFile = async (file: string | File) => {
- const data = await ExifReader.load(file);
- const dateString =
- data.DateTimeOriginal?.description ||
- data.DateTimeDigitized?.description ||
- data.DateTime?.description;
-
- if (typeof dateString !== 'string') return null;
-
- return parse(dateString);
-};
diff --git a/packages/web/src/features/leaderboards/components/add-result/imageUtils.ts b/packages/web/src/features/leaderboards/components/add-result/imageUtils.ts
new file mode 100644
index 00000000..a9b8cf6b
--- /dev/null
+++ b/packages/web/src/features/leaderboards/components/add-result/imageUtils.ts
@@ -0,0 +1,223 @@
+import type { IArea } from '@bmunozg/react-image-area';
+import imageCompression from 'browser-image-compression';
+import { parse } from 'exif-date';
+import * as ExifReader from 'exifreader';
+
+/**
+ * Creates a canvas element from ImageData (cheap pixel copy, no decoding)
+ */
+export const imageDataToCanvas = (imageData: ImageData): HTMLCanvasElement => {
+ const canvas = document.createElement('canvas');
+ canvas.width = imageData.width;
+ canvas.height = imageData.height;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.putImageData(imageData, 0, 0);
+ }
+ return canvas;
+};
+
+export const getDateFromFile = async (file: string | File) => {
+ const data = await ExifReader.load(file);
+ const dateString =
+ data.DateTimeOriginal?.description ||
+ data.DateTimeDigitized?.description ||
+ data.DateTime?.description;
+
+ if (typeof dateString !== 'string') return null;
+
+ return parse(dateString);
+};
+
+export const compressImageData = async (imageData: ImageData): Promise => {
+ const canvas = imageDataToCanvas(imageData);
+ const file = await imageCompression.canvasToFile(canvas, 'image/jpeg', 'image.jpg', 0.95);
+ const compressed = await imageCompression(file, {
+ maxWidthOrHeight: 1200,
+ maxSizeMB: 0.1,
+ initialQuality: 0.8,
+ });
+ return imageCompression.getDataUrlFromFile(compressed);
+};
+
+/**
+ * Calculates how "white" a pixel is (0-255).
+ * White pixels have high values in all channels and similar R, G, B values.
+ * This prioritizes white text over bright colorful backgrounds.
+ */
+const getWhiteness = (r: number, g: number, b: number): number => {
+ // Minimum channel value (a white pixel has all channels high)
+ const minChannel = Math.min(r, g, b);
+
+ // Color spread (white has low spread, colorful has high spread)
+ const maxChannel = Math.max(r, g, b);
+ const spread = maxChannel - minChannel;
+
+ return Math.min(255, Math.max(0, Math.round(minChannel ** 3 / 240 ** 2) - spread * 1));
+};
+
+/**
+ * Shrinks a selection area to fit the white text content within it.
+ * Finds the bounding box of the top X% whitest pixels (prioritizes pixels
+ * that are bright AND have low color saturation, i.e., close to white).
+ * Synchronous - works directly with ImageData.
+ * @param imageData - The source image pixel data
+ * @param naturalSize - The natural size of the square image
+ * @param area - The selected area in display coordinates
+ * @param displayedSize - The size of the image as displayed on screen
+ * @param brightnessPercentile - What percentile of whiteness to consider as "white" (0-100, default 95)
+ */
+export const shrinkAreaToBrightness = (
+ imageData: ImageData,
+ naturalSize: number,
+ area: IArea,
+ displayedSize: number,
+ brightnessPercentile = 95
+): IArea => {
+ const sourcePixels = imageData.data;
+ const sourceWidth = imageData.width;
+
+ // Scale coordinates from displayed size to natural size
+ const scale = naturalSize / displayedSize;
+
+ const scaledX = Math.round(area.x * scale);
+ const scaledY = Math.round(area.y * scale);
+ const scaledWidth = Math.round(area.width * scale);
+ const scaledHeight = Math.round(area.height * scale);
+
+ // Calculate whiteness for pixels in the selected area
+ const whitenessMap: number[] = [];
+ for (let y = 0; y < scaledHeight; y++) {
+ for (let x = 0; x < scaledWidth; x++) {
+ const sourceX = scaledX + x;
+ const sourceY = scaledY + y;
+ const i = (sourceY * sourceWidth + sourceX) * 4;
+ const whiteness = getWhiteness(sourcePixels[i], sourcePixels[i + 1], sourcePixels[i + 2]);
+ whitenessMap.push(whiteness);
+ }
+ }
+
+ // Find the whiteness threshold (top X percentile)
+ const sortedWhiteness = [...whitenessMap].sort((a, b) => b - a);
+ const thresholdIndex = Math.floor((sortedWhiteness.length * (100 - brightnessPercentile)) / 100);
+ const whitenessThreshold = sortedWhiteness[thresholdIndex] || 200;
+
+ // Find bounding box of white pixels
+ let minX = scaledWidth;
+ let maxX = 0;
+ let minY = scaledHeight;
+ let maxY = 0;
+
+ for (let y = 0; y < scaledHeight; y++) {
+ for (let x = 0; x < scaledWidth; x++) {
+ const idx = y * scaledWidth + x;
+ if (whitenessMap[idx] >= whitenessThreshold) {
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ // If no white pixels found, return original area
+ if (minX >= maxX || minY >= maxY) {
+ return area;
+ }
+
+ // Add small padding
+ const paddingX = Math.max(4, (maxX - minX) * 0.025);
+ const paddingY = Math.max(3, (maxY - minY) * 0.02);
+
+ minX = Math.max(0, minX - paddingX);
+ maxX = Math.min(scaledWidth, maxX + paddingX);
+ minY = Math.max(0, minY - paddingY);
+ maxY = Math.min(scaledHeight, maxY + paddingY);
+
+ // Convert back to display coordinates
+ const newArea: IArea = {
+ ...area,
+ x: area.x + minX / scale,
+ y: area.y + minY / scale,
+ width: (maxX - minX) / scale,
+ height: (maxY - minY) / scale,
+ };
+
+ return newArea;
+};
+
+export const applyProcessing = (
+ imageData: ImageData,
+ originalImageData: ImageData,
+ left: number,
+ top: number,
+ width: number,
+ height: number
+) => {
+ const sourcePixels = originalImageData.data;
+
+ for (let i = 0; i < sourcePixels.length; i++) {
+ imageData.data[i] = sourcePixels[i];
+ }
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const sourceX = left + x;
+ const sourceY = top + y;
+ const i = (sourceY * imageData.width + sourceX) * 4;
+ const whiteness = getWhiteness(sourcePixels[i], sourcePixels[i + 1], sourcePixels[i + 2]);
+ imageData.data[i] = 255 - whiteness;
+ imageData.data[i + 1] = 255 - whiteness;
+ imageData.data[i + 2] = 255 - whiteness;
+ imageData.data[i + 3] = 255;
+ }
+ }
+};
+
+/**
+ * Crops a selected area from ImageData and applies whiteness processing.
+ * Returns processed ImageData ready for OCR.
+ * @param imageData - The source image pixel data
+ * @param naturalSize - The natural size of the square image
+ * @param area - The selected area in display coordinates
+ * @param displayedSize - The size of the square image as displayed on screen
+ */
+export const prepareImageForRecognition = (
+ imageData: ImageData,
+ naturalSize: number,
+ area: IArea,
+ displayedSize: number
+): ImageData => {
+ const sourcePixels = imageData.data;
+ const sourceWidth = imageData.width;
+
+ // Scale coordinates from displayed size to natural size
+ const scale = naturalSize / displayedSize;
+
+ const scaledX = Math.round(area.x * scale);
+ const scaledY = Math.round(area.y * scale);
+ const scaledWidth = Math.round(area.width * scale);
+ const scaledHeight = Math.round(area.height * scale);
+
+ // Create new ImageData for the cropped region
+ const croppedImageData = new ImageData(scaledWidth, scaledHeight);
+ const destPixels = croppedImageData.data;
+
+ // Copy pixels from source to destination with whiteness processing
+ for (let y = 0; y < scaledHeight; y++) {
+ for (let x = 0; x < scaledWidth; x++) {
+ const sourceX = scaledX + x;
+ const sourceY = scaledY + y;
+ const srcIdx = (sourceY * sourceWidth + sourceX) * 4;
+ const destIdx = (y * scaledWidth + x) * 4;
+ destPixels[destIdx] = sourcePixels[srcIdx];
+ destPixels[destIdx + 1] = sourcePixels[srcIdx + 1];
+ destPixels[destIdx + 2] = sourcePixels[srcIdx + 2];
+ destPixels[destIdx + 3] = sourcePixels[srcIdx + 3];
+ }
+ }
+
+ applyProcessing(croppedImageData, croppedImageData, 0, 0, scaledWidth, scaledHeight);
+
+ return croppedImageData;
+};
diff --git a/packages/web/src/features/leaderboards/components/add-result/useProcessedImage.ts b/packages/web/src/features/leaderboards/components/add-result/useProcessedImage.ts
index 46e9a9aa..7d67913c 100644
--- a/packages/web/src/features/leaderboards/components/add-result/useProcessedImage.ts
+++ b/packages/web/src/features/leaderboards/components/add-result/useProcessedImage.ts
@@ -1,53 +1,43 @@
import { useEffect, useState } from 'react';
-import { getDateFromFile } from './getDate';
+import { getDateFromFile } from './imageUtils';
export interface ProcessedImage {
- squareDataUrl: string;
+ imageData: ImageData;
+ naturalSize: number;
date: Date | null;
}
+interface CropResult {
+ imageData: ImageData;
+ naturalSize: number;
+}
+
/**
- * Crops an image to a centered square and returns as dataUrl
+ * Crops an image to a centered square and returns ImageData
+ * Uses createImageBitmap for efficient off-main-thread decoding
*/
-const cropToSquare = (imageSrc: string): Promise => {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- const size = Math.min(img.naturalWidth, img.naturalHeight);
- const x = (img.naturalWidth - size) / 2;
- const y = (img.naturalHeight - size) / 2;
-
- const canvas = document.createElement('canvas');
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext('2d');
- if (!ctx) {
- reject(new Error('Could not get canvas context'));
- return;
- }
+const cropToSquare = async (file: File): Promise => {
+ const bitmap = await createImageBitmap(file);
- ctx.drawImage(img, x, y, size, size, 0, 0, size, size);
- resolve(canvas.toDataURL('image/jpeg', 0.95));
- };
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = imageSrc;
- });
-};
+ const size = Math.min(bitmap.width, bitmap.height);
+ const x = (bitmap.width - size) / 2;
+ const y = (bitmap.height - size) / 2;
-const fileToDataUrl = (file: File): Promise => {
- return new Promise((resolve, reject) => {
- const fr = new FileReader();
- fr.onload = () => {
- if (typeof fr.result === 'string') {
- resolve(fr.result);
- } else {
- reject(new Error('Invalid file'));
- }
- };
- fr.onerror = () => reject(new Error('Failed to read file'));
- fr.readAsDataURL(file);
- });
+ const canvas = document.createElement('canvas');
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ bitmap.close();
+ throw new Error('Could not get canvas context');
+ }
+
+ ctx.drawImage(bitmap, x, y, size, size, 0, 0, size, size);
+ bitmap.close();
+
+ const imageData = ctx.getImageData(0, 0, size, size);
+ return { imageData, naturalSize: size };
};
interface UseProcessedImageResult {
@@ -73,13 +63,13 @@ export const useProcessedImage = (file: File | null): UseProcessedImageResult =>
setError(null);
try {
- const originalDataUrl = await fileToDataUrl(file);
- const [squareDataUrl, date] = await Promise.all([
- cropToSquare(originalDataUrl),
- getDateFromFile(originalDataUrl),
- ]);
+ const [cropResult, date] = await Promise.all([cropToSquare(file), getDateFromFile(file)]);
- setProcessedImage({ squareDataUrl, date });
+ setProcessedImage({
+ imageData: cropResult.imageData,
+ naturalSize: cropResult.naturalSize,
+ date,
+ });
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to process image');
setProcessedImage(null);
diff --git a/packages/web/src/features/root/Root.tsx b/packages/web/src/features/root/Root.tsx
index 2538c22d..342edec7 100644
--- a/packages/web/src/features/root/Root.tsx
+++ b/packages/web/src/features/root/Root.tsx
@@ -15,7 +15,7 @@ import { RegistrationPage } from 'features/login/Registration';
import { useUser } from 'hooks/useUser';
-const LazyAddResult = React.lazy(() => import('../leaderboards/AddResult'));
+const LazyAddResult = React.lazy(() => import('../leaderboards/components/add-result/AddResult'));
const LazySingleChartLeaderboard = React.lazy(
() => import('../leaderboards/LeaderboardsSingleChart')
);
diff --git a/packages/web/src/utils/base64.ts b/packages/web/src/utils/base64.ts
deleted file mode 100644
index 01e2a03c..00000000
--- a/packages/web/src/utils/base64.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export const toBase64 = (file: File) =>
- new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => resolve(reader.result?.toString() ?? '');
- reader.onerror = reject;
- });