From aabb674b1bbbdeffdaae3cd669720a2aef8c6b55 Mon Sep 17 00:00:00 2001 From: Marek Date: Sun, 15 Feb 2026 21:09:44 +0100 Subject: [PATCH 1/6] feat: add file validation and image conversion utilities --- client/src/utils/fileValidation.ts | 28 +++++++++++++ client/src/utils/imageConversion.ts | 61 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 client/src/utils/fileValidation.ts create mode 100644 client/src/utils/imageConversion.ts diff --git a/client/src/utils/fileValidation.ts b/client/src/utils/fileValidation.ts new file mode 100644 index 0000000..3727f72 --- /dev/null +++ b/client/src/utils/fileValidation.ts @@ -0,0 +1,28 @@ +import type {Accept, FileRejection} from 'react-dropzone'; + +export const ACCEPTED_FILE_TYPES: Accept = { + 'image/tiff': ['.tiff', '.tif'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpeg', '.jpg'], +}; + +export const ALLOWED_MIME_TYPES = ['image/tiff', 'image/png', 'image/jpeg']; + +export function isAllowedFileType(file: File): boolean { + return ALLOWED_MIME_TYPES.includes(file.type); +} + +export function formatRejectionReasons(rejectedFiles: FileRejection[]): string { + return rejectedFiles + .map((rejection) => + rejection.errors + .map((err) => { + if (err.code === 'file-invalid-type') { + return `${rejection.file.name}: unsupported type`; + } + return `${rejection.file.name}: ${err.message}`; + }) + .join(', ') + ) + .join('; '); +} \ No newline at end of file diff --git a/client/src/utils/imageConversion.ts b/client/src/utils/imageConversion.ts new file mode 100644 index 0000000..4db0877 --- /dev/null +++ b/client/src/utils/imageConversion.ts @@ -0,0 +1,61 @@ +import { + clip_pixels_with_percentiles, + median_blur_image, + gaussian_blur_image, + apply_linear_function, + Image, +} from '@/wasm' +import { + ConversionAlgorithm, + ConversionAlgorithmType, +} from '@/models/algorithms' + +export type ConversionResult = + | { success: true } + | { success: false; error: string } + +export function applyAlgorithm(image: Image, algorithm: ConversionAlgorithm): ConversionResult { + switch (algorithm.type) { + case ConversionAlgorithmType.HotPixelRemoval: + try{ + clip_pixels_with_percentiles(image, algorithm.lowPercentile, algorithm.highPercentile); + } catch (err){ + return { success: false, error: `Hot pixel removal failed: ${err}` }; + } + return { success: true}; + case ConversionAlgorithmType.MedianBlur: + try{ + median_blur_image(image, algorithm.kernelRadius); + return { success: true}; + } catch (err){ + return { success: false, error: `Median blur failed: ${err}` }; + } + case ConversionAlgorithmType.GaussianBlur: + try{ + gaussian_blur_image(image, algorithm.sigma); + return { success: true}; + } catch (err){ + return { success: false, error: `Gaussian blur failed: ${err}` }; + } + case ConversionAlgorithmType.LinearTransform: + const a = Number(algorithm.a); + const b = Number(algorithm.b); + if(!Number.isFinite(a) || !Number.isFinite(b)){ + return { success: false, error: `Linear transform requires numeric a and b` }; + } + try{ + apply_linear_function(image, a, b); + return { success: true}; + } catch (err){ + return { success: false, error: `Linear transform failed: ${err}` }; + } + default: + return { success: false, error: `Unsupported algorithm: ${(algorithm as any).type}` }; +} +} + +export function extractPixels(image: Image): Uint8Array | Uint16Array | null { + if(image.bits_per_sample === 16) return image.pixels_u16() ?? null; + if(image.bits_per_sample === 8) return image.pixels_u8() ?? null; + return null; +} From 2764cb483c72862cf4a0d63a5b613e3c91a2262b Mon Sep 17 00:00:00 2001 From: Marek Date: Tue, 17 Feb 2026 00:04:44 +0100 Subject: [PATCH 2/6] feat: implement file upload and image processing hooks --- client/src/hooks/useFileUpload.ts | 76 ++++++++++++++++++++++++ client/src/hooks/useImageProcessing.ts | 80 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 client/src/hooks/useFileUpload.ts create mode 100644 client/src/hooks/useImageProcessing.ts diff --git a/client/src/hooks/useFileUpload.ts b/client/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..8986501 --- /dev/null +++ b/client/src/hooks/useFileUpload.ts @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'preact/hooks'; +import type { FileRejection } from 'react-dropzone'; +import { load_image, Image } from '@/wasm'; +import { isAllowedFileType, formatRejectionReasons } from '@/utils/fileValidation'; +import { extractPixels } from '@/utils/imageConversion'; + +export type ImageState = { + rawBytes: Uint8Array | null; + uploadedImage: Image | null; + rawPixels: Uint8Array | Uint16Array | null; + imageToConvert: Image | null; + convertedPixels: Uint8Array | Uint16Array | null; +} + +const INITIAL_STATE: ImageState = { + rawBytes: null, + uploadedImage: null, + rawPixels: null, + imageToConvert: null, + convertedPixels: null, +}; + +export function useFileUpload(wasmReady: boolean) { + const [imageState, setImageState] = useState(INITIAL_STATE); + const [errorMessage, setErrorMessage] = useState(); + + const processFile = useCallback( + async (file: File) => { + if (!wasmReady) { + setErrorMessage('WASM engine is not ready - try again in a moment'); + return; + } + if (!isAllowedFileType(file)){ + setErrorMessage(' Unsupported image type. Supported types are: PNG, TIFF, JPEG'); + return; + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + setErrorMessage(undefined); + + try { + const img = load_image(bytes); + const pixels = extractPixels(img); + + if (!pixels) { + setErrorMessage('Failed to extract pixel data from the image'); + return; + } + + setImageState({ + rawBytes: bytes, + uploadedImage: img, + rawPixels: pixels, + imageToConvert: img, + convertedPixels: null, + }); + } catch (err) { + setErrorMessage(`Upload error: ${err}`); + } + }, + [wasmReady] + ); + + const handleFileReject = useCallback((rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length === 0) return; + setErrorMessage(`Rejected files: ${formatRejectionReasons(rejectedFiles)}`); + }, []); + + return { + imageState, + setImageState, + errorMessage, + processFile, + handleFileReject, + } +} \ No newline at end of file diff --git a/client/src/hooks/useImageProcessing.ts b/client/src/hooks/useImageProcessing.ts new file mode 100644 index 0000000..1ab6d9c --- /dev/null +++ b/client/src/hooks/useImageProcessing.ts @@ -0,0 +1,80 @@ +import {useState, useCallback } from 'preact/hooks'; +import { load_image } from '@/wasm'; +import { ConversionAlgorithm, getAlgorithmName } from '@/models/algorithms'; +import { applyAlgorithm, extractPixels } from '@/utils/imageConversion'; +import type { ImageState } from './useFileUpload'; + + +// the delays are used to draw progress bar (may be deleted in the future or adjusted somehow to not cause too much delay for user) +const YIELD_DELAY_MS = 10; +const FINAL_DISPLAY_DELAY_MS = 500; + +export function useImageProcessing( + imageState: ImageState, + setImageState: (fn: (prev: ImageState) => ImageState) => void, + setErrorMessage: (msg: string | undefined) => void +){ + const [isProcessing, setIsProcessing] = useState(false); + const [processingProgress, setProcessingProgress] = useState(0); + const [currentAlgorithm, setCurrentAlgorithm] = useState(''); + + const handleRun = useCallback( + async (algorithms: ConversionAlgorithm[], wasmReady: boolean) => { + const enabledAlgorithms = algorithms.filter((a) => a.enabled); + if (!imageState.rawBytes || !wasmReady) return; + + if (enabledAlgorithms.length === 0){ + setErrorMessage('No algorithms selected'); + return; + } + +// this will interrupt the processing and user would have to re-run the conversion (maybe it should be changed or at least a warning should be added in UI) + if(!imageState.imageToConvert){ + const imageToConvert = load_image(imageState.rawBytes); + setImageState((prev) => ({...prev, imageToConvert })); + return; + } + + setErrorMessage(undefined); + setIsProcessing(true); + setProcessingProgress(0); + setCurrentAlgorithm(''); + + try{ + for (let i=0;i setTimeout(resolve,YIELD_DELAY_MS)); + } + + const converted = extractPixels(imageState.imageToConvert); + + setImageState((prev) => ({ ...prev, convertedPixels: converted })); + + setProcessingProgress(100); + setCurrentAlgorithm('Complete'); + setErrorMessage(undefined); + + await new Promise((resolve) => setTimeout(resolve, FINAL_DISPLAY_DELAY_MS)); + } catch (error) { + const msg = error instanceof Error ? error.message : JSON.stringify(error); + setErrorMessage(`Processing error: ${msg}`); + } finally { + setIsProcessing(false); + setProcessingProgress(0); + setCurrentAlgorithm(''); + } + }, + [imageState, setImageState, setErrorMessage] + ); + + return { isProcessing, processingProgress, currentAlgorithm, handleRun }; +} \ No newline at end of file From ed3191c75defe0a064dd0350ef70a04e4f1646ad Mon Sep 17 00:00:00 2001 From: Marek Date: Tue, 17 Feb 2026 23:58:12 +0100 Subject: [PATCH 3/6] refactor: simplify ImageConverter component and enhance file upload handling --- .../src/components/images/ImageConverter.tsx | 320 +++--------------- client/src/hooks/useFileUpload.ts | 1 + 2 files changed, 39 insertions(+), 282 deletions(-) diff --git a/client/src/components/images/ImageConverter.tsx b/client/src/components/images/ImageConverter.tsx index 1a6a499..856833f 100644 --- a/client/src/components/images/ImageConverter.tsx +++ b/client/src/components/images/ImageConverter.tsx @@ -1,301 +1,63 @@ -import { useState, useRef, useEffect } from 'preact/hooks'; -import { TargetedEvent } from 'preact/compat'; -import type { Accept, FileRejection } from 'react-dropzone'; +import { useState, useRef } from 'preact/hooks'; import { useWasm } from '@/hooks/useWasm'; -import ImageJSRootPreview from './ImageJSRootPreview'; -import { - clip_pixels_with_percentiles, - median_blur_image, - load_image, - gaussian_blur_image, - apply_linear_function, - Image, -} from '@/wasm'; -import AlgorithmsContainer from '@/components/algorithms/AlgorithmsContainer'; -import { - ConversionAlgorithm, - ConversionAlgorithmType, - getAlgorithmName, -} from '@/models/algorithms'; -import DragAndDropZone from '@/components/common/DragAndDropZone'; +import { useFileUpload } from '@/hooks/useFileUpload'; +import { useImageProcessing } from '@/hooks/useImageProcessing'; +import { ACCEPTED_FILE_TYPES } from '@/utils/fileValidation'; +import { ConversionAlgorithm } from '@/models/algorithms'; -type ImageState = { - rawBytes: Uint8Array | null; - uploadedImage: Image | null; - rawPixels: Uint8Array | Uint16Array | null; - imageToConvert: Image | null; - convertedPixels: Uint8Array | Uint16Array | null; -}; +import ImagePreview from './ImageJSRootPreview'; +import AlgorithmsContainer from '../algorithms/AlgorithmsContainer'; +import DragAndDropZone from '../common/DragAndDropZone'; const ImageConverter = () => { const { wasmReady } = useWasm(); const [algorithms, setAlgorithms] = useState([]); - - const [imageState, setImageState] = useState({ - rawBytes: null, - uploadedImage: null, - rawPixels: null, - imageToConvert: null, - convertedPixels: null, - }); - - const [errorMessage, setErrorMessage] = useState(); - const [isProcessing, setIsProcessing] = useState(false); - const [processingProgress, setProcessingProgress] = useState(0); - const [currentAlgorithm, setCurrentAlgorithm] = useState(''); const [units, setUnits] = useState<'px' | 'mm'>('px'); const [mmPerPx, setMmPerPx] = useState(16 / 10); - const [previewsAspectRatios, setPreviewsAspectRatios] = useState(1); + const [previewsAspectRatios, setPreviewsAspectRatios] = useState(1); const overlayTargetRef = useRef(null); - const prevSrcUrlRef = useRef(null); - const prevResultUrlRef = useRef(null); - const YIELD_DELAY_MS = 10; - const FINAL_DISPLAY_DELAY_MS = 500; + const { + imageState, + setImageState, + errorMessage, + setErrorMessage, + processFile, + handleFileReject, + } = useFileUpload(wasmReady); - const acceptedFileTypes: Accept = { - 'image/tiff': ['.tiff', '.tif'], - 'image/png': ['.png'], - 'image/jpeg': ['.jpeg', '.jpg'], - }; + const { isProcessing, processingProgress, currentAlgorithm, handleRun } = + useImageProcessing(imageState, setImageState, setErrorMessage); - const handleFileReject = (rejectedFiles: FileRejection[]) => { - if (rejectedFiles.length === 0) return; - const reasons = rejectedFiles - .map((rejection) => { - const errors = rejection.errors - .map((err) => { - if (err.code === 'file-invalid-type') { - return `${rejection.file.name}: unsupported type`; - } - return `${rejection.file.name}: ${err.message}`; - }) - .join(', '); - return errors; - }) - .join('; '); - - setErrorMessage(`Rejected files: ${reasons}`); - }; - - const cleanupBlobUrls = () => { - if (prevSrcUrlRef.current) { - URL.revokeObjectURL(prevSrcUrlRef.current); - prevSrcUrlRef.current = null; - } - if (prevResultUrlRef.current) { - URL.revokeObjectURL(prevResultUrlRef.current); - prevResultUrlRef.current = null; - } - }; - - useEffect(() => { - return () => { - cleanupBlobUrls(); - }; - }, []); - - const processFile = async (file: File) => { - if (!wasmReady) { - setErrorMessage('WASM engine not ready — try again in a moment'); - return; - } - const allowedTypes = ['image/tiff', 'image/png', 'image/jpeg']; - if (!allowedTypes.includes(file.type)) { - setErrorMessage('Unsupported image type. Supported: [png, jpeg, tiff]'); - return; - } - const bytes = new Uint8Array(await file.arrayBuffer()); - setErrorMessage(undefined); - try { - const img = load_image(bytes); - const pixels = - img.bits_per_sample === 16 - ? img.pixels_u16() - : img.bits_per_sample === 8 - ? img.pixels_u8() - : null; - if (!pixels) { - setErrorMessage('Failed to extract pixel data from image'); - return; - } - setImageState({ - rawBytes: bytes, - uploadedImage: img, - rawPixels: pixels, - imageToConvert: img, - convertedPixels: null, - }); - } catch (err) { - setErrorMessage(`Upload error: ${err}`); - } - }; - - const handleUpload = async (e: TargetedEvent) => { - const file = e.currentTarget.files?.[0]; + const handleUpload = async (e: Event) => { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; if (file) await processFile(file); }; - const handleRun = async () => { - const enabledAlgorithms = algorithms.filter((a) => a.enabled); - if (!imageState.rawBytes || !wasmReady) return; - if (enabledAlgorithms.length === 0) { - setErrorMessage('No algorithms selected'); - return; - } - if (!imageState.imageToConvert) { - const imageToConvert = load_image(imageState.rawBytes); - setImageState((prevState) => ({ - ...prevState, - imageToConvert, - })); - return; - } - - setErrorMessage(undefined); - setIsProcessing(true); - setProcessingProgress(0); - setCurrentAlgorithm(''); - - try { - for (let i = 0; i < enabledAlgorithms.length; i++) { - const algorithm = enabledAlgorithms[i]; - - setCurrentAlgorithm(getAlgorithmName(algorithm.type)); - setProcessingProgress(Math.round((i / enabledAlgorithms.length) * 100)); - - try { - convert(imageState.imageToConvert, algorithm, setErrorMessage); - } catch (error) { - console.error( - `Error occurred while processing algorithm ${algorithm.type}:`, - error - ); - return; - } - await new Promise((resolve) => setTimeout(resolve, YIELD_DELAY_MS)); - } - - const finalImage = imageState.imageToConvert; - let converted: Uint8Array | Uint16Array | null = null; - if (finalImage) { - if (finalImage.bits_per_sample === 16) { - converted = finalImage.pixels_u16() ?? null; - } else if (finalImage.bits_per_sample === 8) { - converted = finalImage.pixels_u8() ?? null; - } - } - - setImageState((prev) => ({ - ...prev, - convertedPixels: converted, - })); - - setProcessingProgress(100); - setCurrentAlgorithm('Complete'); - - if (prevResultUrlRef.current) { - URL.revokeObjectURL(prevResultUrlRef.current); - } - - setErrorMessage(undefined); - - await new Promise((resolve) => - setTimeout(resolve, FINAL_DISPLAY_DELAY_MS) - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : JSON.stringify(error); - setErrorMessage(`Processing error: ${errorMessage}`); - } finally { - setIsProcessing(false); - setProcessingProgress(0); - setCurrentAlgorithm(''); - } - }; - - const convert = ( - image: Image, - algorithm: ConversionAlgorithm, - setErrorMessage: (msg: string) => void - ): Uint8Array | undefined => { - switch (algorithm.type) { - case ConversionAlgorithmType.HotPixelRemoval: - clip_pixels_with_percentiles( - image, - algorithm.lowPercentile, - algorithm.highPercentile - ); - return; - case ConversionAlgorithmType.MedianBlur: - try { - median_blur_image(image, algorithm.kernelRadius); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - case ConversionAlgorithmType.GaussianBlur: - try { - gaussian_blur_image(image, algorithm.sigma); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - case ConversionAlgorithmType.LinearTransform: { - const a = Number(algorithm.a); - const b = Number(algorithm.b); - if (!Number.isFinite(a) || !Number.isFinite(b)) { - setErrorMessage('Linear transform requires numeric a and b'); - return; - } - try { - // This mutates the Image in-place (same pattern as gaussian_blur_image) - apply_linear_function(image, a, b); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - } - default: - // fallback for unsupported algorithms - // This should ideally never happen if the algorithm list is properly managed - // That is why we use `as any` to avoid TypeScript errors - setErrorMessage(`Unsupported algorithm: ${(algorithm as any).type}`); - return; - } - }; - return (
+ {/* Left panel: previews */}
+ {/* Original image */}
- { />
+ + {/* Converted image */}
- - {/* Progress Indicator */} {isProcessing && (
@@ -338,7 +95,7 @@ const ImageConverter = () => {
+ />
{processingProgress}% @@ -348,6 +105,8 @@ const ImageConverter = () => { )}
+ + {/* Right panel: controls */}