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
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:
@@ -1 +0,0 @@
|
||||
404: Not Found
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './processor.js';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user