feat(image-service): standalone processor

This commit is contained in:
2026-02-22 21:59:14 +01:00
parent 4f6d62a85c
commit 21c0c778f9
11 changed files with 812 additions and 136 deletions

View File

@@ -1,14 +1,28 @@
node_modules node_modules
**/node_modules
.next .next
**/.next
.git .git
# .npmrc is allowed as it contains the registry template # .npmrc is allowed as it contains the registry template
dist dist
**/dist
build build
**/build
out out
**/out
coverage coverage
**/coverage
.vercel .vercel
**/.vercel
.turbo .turbo
**/.turbo
*.log *.log
**/*.log
.DS_Store .DS_Store
**/.DS_Store
.pnpm-store .pnpm-store
**/.pnpm-store
.gitea .gitea
**/.gitea
models
**/models

2
.env
View File

@@ -1,5 +1,5 @@
# Project # Project
IMAGE_TAG=v1.8.10 IMAGE_TAG=v1.8.6
PROJECT_NAME=at-mintel PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582 GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582

View File

@@ -192,6 +192,9 @@ jobs:
- image: directus - image: directus
file: packages/infra/docker/Dockerfile.directus file: packages/infra/docker/Dockerfile.directus
name: Directus (Base) name: Directus (Base)
- image: image-processor
file: apps/image-service/Dockerfile
name: Image Processor
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -0,0 +1,23 @@
{
"name": "image-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src"
},
"dependencies": {
"@mintel/image-processor": "workspace:*",
"fastify": "^4.26.2"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsx": "^4.7.1",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,80 @@
import Fastify from "fastify";
import { processImageWithSmartCrop } from "@mintel/image-processor";
const fastify = Fastify({
logger: true,
});
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
// Compatibility endpoint for old imgproxy calls (optional, but requested by some systems sometimes)
// For now, replacing logic in clients is preferred. So we just redirect or error.
return reply
.status(400)
.send({ error: "Legacy imgproxy API not supported. Use /process" });
});
fastify.get("/process", async (request, reply) => {
const query = request.query as {
url?: string;
w?: string;
h?: string;
q?: string;
format?: string;
};
const { url } = query;
const width = parseInt(query.w || "800", 10);
const height = parseInt(query.h || "600", 10);
const quality = parseInt(query.q || "80", 10);
const format = (query.format || "webp") as "webp" | "jpeg" | "png" | "avif";
if (!url) {
return reply.status(400).send({ error: 'Parameter "url" is required' });
}
try {
const response = await fetch(url);
if (!response.ok) {
return reply
.status(response.status)
.send({
error: `Failed to fetch source image: ${response.statusText}`,
});
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const processedBuffer = await processImageWithSmartCrop(buffer, {
width,
height,
format,
quality,
});
reply.header("Content-Type", `image/${format}`);
reply.header("Cache-Control", "public, max-age=31536000, immutable");
return reply.send(processedBuffer);
} catch (err) {
fastify.log.error(err);
return reply
.status(500)
.send({ error: "Internal Server Error processing image" });
}
});
fastify.get("/health", async () => {
return { status: "ok" };
});
const start = async () => {
try {
await fastify.listen({ port: 8080, host: "0.0.0.0" });
console.log(`Server listening on 8080`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false
},
"include": ["src/**/*"]
}

View File

@@ -59,6 +59,18 @@
}, },
"version": "1.8.6", "version": "1.8.6",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@sentry/cli",
"@swc/core",
"@tensorflow/tfjs-node",
"canvas",
"core-js",
"esbuild",
"sharp",
"unrs-resolver",
"vue-demi"
],
"overrides": { "overrides": {
"next": "16.1.6", "next": "16.1.6",
"@sentry/nextjs": "10.38.0" "@sentry/nextjs": "10.38.0"

View File

@@ -18,6 +18,7 @@
"lint": "eslint src" "lint": "eslint src"
}, },
"dependencies": { "dependencies": {
"@tensorflow/tfjs-node": "^4.22.0",
"@vladmandic/face-api": "^1.7.13", "@vladmandic/face-api": "^1.7.13",
"canvas": "^2.11.2", "canvas": "^2.11.2",
"sharp": "^0.33.2" "sharp": "^0.33.2"

View File

@@ -1,13 +1,18 @@
import * as fs from 'node:fs'; import * as fs from "node:fs";
import * as path from 'node:path'; import * as path from "node:path";
import * as https from 'node:https'; import * as https from "node:https";
const MODELS_DIR = path.join(process.cwd(), 'models'); import { fileURLToPath } from "node:url";
const BASE_URL = 'https://raw.githubusercontent.com/vladmandic/face-api/master/model/';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MODELS_DIR = path.join(__dirname, "..", "models");
const BASE_URL =
"https://raw.githubusercontent.com/vladmandic/face-api/master/model/";
const models = [ const models = [
'tiny_face_detector_model-weights_manifest.json', "tiny_face_detector_model-weights_manifest.json",
'tiny_face_detector_model-shard1' "tiny_face_detector_model-shard1",
]; ];
async function downloadModel(filename: string) { async function downloadModel(filename: string) {
@@ -20,13 +25,15 @@ async function downloadModel(filename: string) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
console.log(`Downloading ${filename}...`); console.log(`Downloading ${filename}...`);
const file = fs.createWriteStream(destPath); const file = fs.createWriteStream(destPath);
https.get(BASE_URL + filename, (response) => { https
.get(BASE_URL + filename, (response) => {
response.pipe(file); response.pipe(file);
file.on('finish', () => { file.on("finish", () => {
file.close(); file.close();
resolve(true); resolve(true);
}); });
}).on('error', (err) => { })
.on("error", (err) => {
fs.unlinkSync(destPath); fs.unlinkSync(destPath);
reject(err); reject(err);
}); });
@@ -42,7 +49,7 @@ async function main() {
await downloadModel(model); await downloadModel(model);
} }
console.log('All models downloaded successfully!'); console.log("All models downloaded successfully!");
} }
main().catch(console.error); main().catch(console.error);

View File

@@ -1,18 +1,18 @@
import * as faceapi from '@vladmandic/face-api'; import * as faceapi from "@vladmandic/face-api";
// Provide Canvas fallback for face-api in Node.js // Provide Canvas fallback for face-api in Node.js
import { Canvas, Image, ImageData } from 'canvas'; import { Canvas, Image, ImageData } from "canvas";
import sharp from 'sharp'; import sharp from "sharp";
import * as path from 'node:path'; import * as path from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
// @ts-ignore // @ts-expect-error FaceAPI does not have type definitions for monkeyPatch
faceapi.env.monkeyPatch({ Canvas, Image, ImageData }); faceapi.env.monkeyPatch({ Canvas, Image, ImageData });
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Path to the downloaded models // Path to the downloaded models
const MODELS_PATH = path.join(__dirname, '..', 'models'); const MODELS_PATH = path.join(__dirname, "..", "models");
let isModelsLoaded = false; let isModelsLoaded = false;
@@ -25,13 +25,13 @@ async function loadModels() {
export interface ProcessImageOptions { export interface ProcessImageOptions {
width: number; width: number;
height: number; height: number;
format?: 'webp' | 'jpeg' | 'png' | 'avif'; format?: "webp" | "jpeg" | "png" | "avif";
quality?: number; quality?: number;
} }
export async function processImageWithSmartCrop( export async function processImageWithSmartCrop(
inputBuffer: Buffer, inputBuffer: Buffer,
options: ProcessImageOptions options: ProcessImageOptions,
): Promise<Buffer> { ): Promise<Buffer> {
await loadModels(); await loadModels();
@@ -41,16 +41,16 @@ export async function processImageWithSmartCrop(
// Detect faces // Detect faces
const detections = await faceapi.detectAllFaces( const detections = await faceapi.detectAllFaces(
// @ts-ignore // @ts-expect-error FaceAPI does not have type definitions for monkeyPatch
img, img,
new faceapi.TinyFaceDetectorOptions() new faceapi.TinyFaceDetectorOptions(),
); );
const sharpImage = sharp(inputBuffer); const sharpImage = sharp(inputBuffer);
const metadata = await sharpImage.metadata(); const metadata = await sharpImage.metadata();
if (!metadata.width || !metadata.height) { if (!metadata.width || !metadata.height) {
throw new Error('Could not read image metadata'); throw new Error("Could not read image metadata");
} }
// If faces are found, calculate the bounding box containing all faces // If faces are found, calculate the bounding box containing all faces
@@ -105,33 +105,34 @@ export async function processImageWithSmartCrop(
if (cropX < 0) cropX = 0; if (cropX < 0) cropX = 0;
if (cropY < 0) cropY = 0; if (cropY < 0) cropY = 0;
if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth; if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth;
if (cropY + cropHeight > metadata.height) cropY = metadata.height - cropHeight; if (cropY + cropHeight > metadata.height)
cropY = metadata.height - cropHeight;
sharpImage.extract({ sharpImage.extract({
left: cropX, left: cropX,
top: cropY, top: cropY,
width: cropWidth, width: cropWidth,
height: cropHeight height: cropHeight,
}); });
} }
// Finally, resize to the requested dimensions and format // Finally, resize to the requested dimensions and format
let finalImage = sharpImage.resize(options.width, options.height, { let finalImage = sharpImage.resize(options.width, options.height, {
// If faces weren't found, default to entropy/attention based cropping as fallback // If faces weren't found, default to entropy/attention based cropping as fallback
fit: 'cover', fit: "cover",
position: detections.length > 0 ? 'center' : 'attention' position: detections.length > 0 ? "center" : "attention",
}); });
const format = options.format || 'webp'; const format = options.format || "webp";
const quality = options.quality || 80; const quality = options.quality || 80;
if (format === 'webp') { if (format === "webp") {
finalImage = finalImage.webp({ quality }); finalImage = finalImage.webp({ quality });
} else if (format === 'jpeg') { } else if (format === "jpeg") {
finalImage = finalImage.jpeg({ quality }); finalImage = finalImage.jpeg({ quality });
} else if (format === 'png') { } else if (format === "png") {
finalImage = finalImage.png({ quality }); finalImage = finalImage.png({ quality });
} else if (format === 'avif') { } else if (format === "avif") {
finalImage = finalImage.avif({ quality }); finalImage = finalImage.avif({ quality });
} }

528
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff