refactor: drop legacy image-processor and directus from pipeline
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 12s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m31s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m4s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-02-27 19:26:19 +01:00
parent 526db11104
commit f275b8c9f6
16 changed files with 33 additions and 1299 deletions

View File

@@ -191,9 +191,7 @@ jobs:
- image: gatekeeper
file: packages/infra/docker/Dockerfile.gatekeeper
name: Gatekeeper (Product)
- image: directus
file: packages/infra/docker/Dockerfile.directus
name: Directus (Base)
- image: image-processor
file: apps/image-service/Dockerfile
name: Image Processor

View File

@@ -1,40 +0,0 @@
FROM node:20.18-bookworm-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apt-get update && apt-get install -y \
build-essential \
python3 \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
libexpat1 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@10.30.1
FROM base AS build
WORKDIR /app
COPY . .
# We only need standard pnpm install now, no C++ tools needed for basic Sharp
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @mintel/image-processor build
RUN pnpm --filter image-service build
FROM base
WORKDIR /app
# Instead of copying node_modules which contains native C++ bindings for canvas and tfjs-node,
# we copy the package.json files and install natively in the final stage so the bindings are correct.
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/image-service/package.json ./apps/image-service/package.json
COPY packages/image-processor/package.json ./packages/image-processor/package.json
RUN pnpm install --frozen-lockfile --filter image-service...
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
COPY --from=build /app/packages/image-processor/models ./packages/image-processor/models
EXPOSE 8080
WORKDIR /app/apps/image-service
CMD ["npm", "run", "start"]

View File

@@ -1,23 +0,0 @@
{
"name": "image-service",
"version": "1.9.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

@@ -1,109 +0,0 @@
import Fastify from "fastify";
import {
processImageWithSmartCrop,
parseImgproxyOptions,
mapUrl,
} from "@mintel/image-processor";
const fastify = Fastify({
logger: true,
});
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
const { options, urlSafeB64 } = request.params as {
options: string;
urlSafeB64: string;
};
// urlSafeB64 might be "plain/http://..." or a Base64 string
let url = "";
if (urlSafeB64.startsWith("plain/")) {
url = urlSafeB64.substring(6);
} else {
try {
url = Buffer.from(urlSafeB64, "base64").toString("utf-8");
} catch (e) {
return reply.status(400).send({ error: "Invalid Base64 URL" });
}
}
const parsedOptions = parseImgproxyOptions(options);
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
return handleProcessing(mappedUrl, parsedOptions, reply);
});
// Helper to avoid duplication
async function handleProcessing(url: string, options: any, reply: any) {
const width = options.width || 800;
const height = options.height || 600;
const quality = options.quality || 80;
const format = options.format || "webp";
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("/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 any;
if (!url) {
return reply.status(400).send({ error: 'Parameter "url" is required' });
}
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
return handleProcessing(mappedUrl, { width, height, quality, format }, reply);
});
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

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

View File

@@ -2,12 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: [
"@mintel/image-processor",
"@tensorflow/tfjs-node",
"sharp",
"canvas",
],
transpilePackages: ["@mintel/ui"],
};
export default mintelNextConfig(nextConfig);

View File

@@ -15,7 +15,6 @@
"pagespeed:test": "mintel pagespeed"
},
"dependencies": {
"@mintel/image-processor": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",

View File

@@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
const width = parseInt(searchParams.get("w") || "800");
const height = parseInt(searchParams.get("h") || "600");
const q = parseInt(searchParams.get("q") || "80");
if (!url) {
return NextResponse.json(
{ error: "Missing url parameter" },
{ status: 400 },
);
}
try {
// 1. Fetch image from original URL
const response = await fetch(url);
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to fetch original image" },
{ status: response.status },
);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Dynamically import to prevent Next.js from trying to bundle tfjs-node/sharp locally at build time
const { processImageWithSmartCrop } =
await import("@mintel/image-processor");
// 2. Process image with Face-API and Sharp
const processedBuffer = await processImageWithSmartCrop(buffer, {
width,
height,
format: "webp",
quality: q,
});
// 3. Return the processed image
return new NextResponse(new Uint8Array(processedBuffer), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Image Processing Error:", error);
return NextResponse.json(
{ error: "Failed to process image" },
{ status: 500 },
);
}
}

View File

@@ -1 +0,0 @@
404: Not Found

View File

@@ -1,30 +0,0 @@
[
{
"weights":
[
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
],
"paths":
[
"tiny_face_detector_model.bin"
]
}
]

View File

@@ -1,34 +0,0 @@
{
"name": "@mintel/image-processor",
"version": "1.9.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint src"
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"canvas": "^3.2.1",
"sharp": "^0.33.2"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsup": "^8.3.5",
"typescript": "^5.0.0"
}
}

View File

@@ -1 +0,0 @@
export * from './processor.js';

View File

@@ -1,217 +0,0 @@
import sharp from "sharp";
import { Canvas, Image, ImageData } from "canvas";
// Use the ESM no-bundle build to avoid the default Node entrypoint
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings).
import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
import * as tf from "@tensorflow/tfjs";
import path from "path";
import { fileURLToPath } from "url";
// Polyfill required by face-api for Node.js
faceapi.env.monkeyPatch({ Canvas, Image, ImageData } as any);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MODEL_URL = path.join(__dirname, "../models");
// State flag to ensure we only load weights once
let modelsLoaded = false;
async function loadModelsOnce() {
if (modelsLoaded) return;
// Initialize pure JS CPU backend (no native bindings needed)
await tf.setBackend("cpu");
await tf.ready();
// Load the microscopic TinyFaceDetector (~190KB)
await faceapi.nets.tinyFaceDetector.loadFromDisk(MODEL_URL);
modelsLoaded = true;
}
export interface ProcessImageOptions {
width: number;
height: number;
format?: "webp" | "jpeg" | "png" | "avif";
quality?: number;
}
/**
* Maps a URL based on the IMGPROXY_URL_MAPPING environment variable.
* Format: "match1:replace1,match2:replace2"
*/
export function mapUrl(url: string, mappingString?: string): string {
if (!mappingString) return url;
const mappings = mappingString.split(",").map((m) => {
if (m.includes("|")) {
return m.split("|");
}
// Legacy support for simple "host:target" or cases where one side might have a protocol
// We try to find the split point that isn't part of a protocol "://"
const colonIndices = [];
for (let i = 0; i < m.length; i++) {
if (m[i] === ":") {
// Check if this colon is part of "://"
if (!(m[i + 1] === "/" && m[i + 2] === "/")) {
colonIndices.push(i);
}
}
}
if (colonIndices.length === 0) return [m];
// In legacy mode with colons, we take the LAST non-protocol colon as the separator
// This handles "http://host:port" or "host:http://target" better
const lastColon = colonIndices[colonIndices.length - 1];
return [m.substring(0, lastColon), m.substring(lastColon + 1)];
});
let mappedUrl = url;
for (const [match, replace] of mappings) {
if (match && replace && url.includes(match)) {
mappedUrl = url.replace(match, replace);
}
}
return mappedUrl;
}
/**
* Parses legacy imgproxy options string.
* Example: rs:fill:300:400/q:80
*/
export function parseImgproxyOptions(
optionsStr: string,
): Partial<ProcessImageOptions> {
const parts = optionsStr.split("/");
const options: Partial<ProcessImageOptions> = {};
for (const part of parts) {
if (part.startsWith("rs:")) {
const [, , w, h] = part.split(":");
if (w) options.width = parseInt(w, 10);
if (h) options.height = parseInt(h, 10);
} else if (part.startsWith("q:")) {
const q = part.split(":")[1];
if (q) options.quality = parseInt(q, 10);
} else if (part.startsWith("ext:")) {
const ext = part.split(":")[1] as any;
if (["webp", "jpeg", "png", "avif"].includes(ext)) {
options.format = ext;
}
}
}
return options;
}
export async function processImageWithSmartCrop(
inputBuffer: Buffer,
options: ProcessImageOptions,
): Promise<Buffer> {
const sharpImage = sharp(inputBuffer);
const metadata = await sharpImage.metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Could not read image metadata");
}
// Load ML models (noop if already loaded)
await loadModelsOnce();
// Convert sharp image to a Node-compatible canvas Image for face-api
const jpegBuffer = await sharpImage.jpeg().toBuffer();
const img = new Image();
img.src = jpegBuffer;
const canvas = new Canvas(img.width, img.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
// Detect faces locally using the tiny model
// Requires explicit any cast since the types expect HTML elements in browser contexts
const detections = await faceapi.detectAllFaces(
canvas as any,
new faceapi.TinyFaceDetectorOptions(),
);
let cropPosition: "center" | "attention" | number = "attention"; // Fallback to sharp's attention if no faces
if (detections.length > 0) {
// We have faces! Calculate the bounding box that contains all of them
let minX = metadata.width;
let minY = metadata.height;
let maxX = 0;
let maxY = 0;
for (const det of detections) {
const box = det.box;
if (box.x < minX) minX = Math.max(0, box.x);
if (box.y < minY) minY = Math.max(0, box.y);
if (box.x + box.width > maxX)
maxX = Math.min(metadata.width, box.x + box.width);
if (box.y + box.height > maxY)
maxY = Math.min(metadata.height, box.y + box.height);
}
const centerX = Math.floor(minX + (maxX - minX) / 2);
const centerY = Math.floor(minY + (maxY - minY) / 2);
const targetRatio = options.width / options.height;
const currentRatio = metadata.width / metadata.height;
let cropWidth = metadata.width;
let cropHeight = metadata.height;
// Determine the maximal crop window that maintains aspect ratio
if (currentRatio > targetRatio) {
cropWidth = Math.floor(metadata.height * targetRatio);
} else {
cropHeight = Math.floor(metadata.width / targetRatio);
}
// Center the crop window over the center of the faces
let cropX = Math.floor(centerX - cropWidth / 2);
let cropY = Math.floor(centerY - cropHeight / 2);
// Keep crop window inside image bounds
if (cropX < 0) cropX = 0;
if (cropY < 0) cropY = 0;
if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth;
if (cropY + cropHeight > metadata.height)
cropY = metadata.height - cropHeight;
// Pre-crop the image to isolate the faces before resizing
sharpImage.extract({
left: cropX,
top: cropY,
width: cropWidth,
height: cropHeight,
});
// As we manually calculated the exact focal box, we can now just center it
cropPosition = "center";
}
let finalImage = sharpImage.resize(options.width, options.height, {
fit: "cover",
position: cropPosition,
});
const format = options.format || "webp";
const quality = options.quality || 80;
if (format === "webp") {
finalImage = finalImage.webp({ quality });
} else if (format === "jpeg") {
finalImage = finalImage.jpeg({ quality });
} else if (format === "png") {
finalImage = finalImage.png({ quality });
} else if (format === "avif") {
finalImage = finalImage.avif({ quality });
}
return finalImage.toBuffer();
}

View File

@@ -1,19 +0,0 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"allowJs": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}

View File

@@ -1,17 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
// Bundle face-api and tensorflow inline (they're pure JS).
// Keep sharp and canvas external (they have native C++ bindings).
noExternal: ["@vladmandic/face-api", "@tensorflow/tfjs"],
external: [
"sharp",
"canvas",
"@tensorflow/tfjs-backend-wasm",
"@tensorflow/tfjs-backend-wasm/dist/index.js",
],
});

758
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff