Migrate from .NET MAUI to Expo + Convex

Complete rewrite of Scry using TypeScript stack:

- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)

Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support

Removes all .NET/MAUI code (src/, test/, tools/).

💘 Generated with Crush

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

View file

@ -0,0 +1,121 @@
/**
* Skia-based image decoder for getting RGBA pixel data.
* Uses react-native-skia to decode images and extract pixel buffers.
*/
import {
Skia,
useImage,
SkImage,
} from "@shopify/react-native-skia";
/**
* Decode a base64 PNG/JPEG image and return RGBA pixel data.
*
* @param base64 - Base64 encoded image data (without data URI prefix)
* @returns RGBA pixel data with dimensions
*/
export function decodeImageBase64(base64: string): {
pixels: Uint8Array;
width: number;
height: number;
} | null {
try {
// Decode base64 to data
const data = Skia.Data.fromBase64(base64);
if (!data) return null;
// Create image from data
const image = Skia.Image.MakeImageFromEncoded(data);
if (!image) return null;
const width = image.width();
const height = image.height();
// Read pixels from the image
// Note: Skia images are in RGBA format
const pixels = image.readPixels(0, 0, {
width,
height,
colorType: 4, // RGBA_8888
alphaType: 1, // Unpremultiplied
});
if (!pixels) return null;
return {
pixels: new Uint8Array(pixels),
width,
height,
};
} catch (error) {
console.error("[SkiaDecoder] Failed to decode image:", error);
return null;
}
}
/**
* Decode an image from a file URI and return RGBA pixel data.
*
* @param uri - File URI (e.g., file:///path/to/image.png)
* @returns Promise with RGBA pixel data
*/
export async function decodeImageFromUri(uri: string): Promise<{
pixels: Uint8Array;
width: number;
height: number;
} | null> {
try {
// Fetch the image data
const response = await fetch(uri);
const arrayBuffer = await response.arrayBuffer();
const base64 = arrayBufferToBase64(arrayBuffer);
return decodeImageBase64(base64);
} catch (error) {
console.error("[SkiaDecoder] Failed to load image from URI:", error);
return null;
}
}
/**
* Convert ArrayBuffer to base64 string.
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* React hook to decode an image from URI.
* Uses Skia's useImage hook for caching.
*/
export function useDecodedImage(uri: string | null) {
const skiaImage = useImage(uri);
if (!skiaImage) {
return { loading: true, pixels: null, width: 0, height: 0 };
}
const width = skiaImage.width();
const height = skiaImage.height();
const pixels = skiaImage.readPixels(0, 0, {
width,
height,
colorType: 4, // RGBA_8888
alphaType: 1, // Unpremultiplied
});
return {
loading: false,
pixels: pixels ? new Uint8Array(pixels) : null,
width,
height,
};
}