Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e15c3f4a | |||
| cd4c2193ce | |||
| df7a464e03 | |||
| e2e0653de6 | |||
| 590ae6f69b | |||
| 2a169f1dfc | |||
| 1bbe89c879 | |||
| 554ca81c9b | |||
| aac0fe81b9 | |||
| ada1e9c717 |
2
.env
2
.env
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.8.11
|
IMAGE_TAG=v1.8.16
|
||||||
PROJECT_NAME=at-mintel
|
PROJECT_NAME=at-mintel
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.8.12
|
IMAGE_TAG=v1.8.19
|
||||||
PROJECT_NAME=sample-website
|
PROJECT_NAME=sample-website
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
|||||||
@@ -6,34 +6,21 @@ RUN npm install -g pnpm@10.30.1
|
|||||||
FROM base AS build
|
FROM base AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
# Note: Canvas needs build tools on Debian
|
# We only need standard pnpm install now, no C++ tools needed for basic Sharp
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
# Force tfjs-node to build the native addon from source so it compiles for arm64 (bypassing pnpm quirks)
|
|
||||||
RUN for f in $(find /app/node_modules/.pnpm -path "*/@tensorflow/tfjs-node/scripts/install.js"); do cd $(dirname $(dirname $f)) && npm run install -- build-addon-from-source; done
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
# Generate models explicitly for Docker
|
|
||||||
RUN ls -la packages/image-processor/scripts || true
|
|
||||||
RUN pnpm dlx tsx packages/image-processor/scripts/download-models.ts
|
|
||||||
RUN pnpm --filter @mintel/image-processor build
|
RUN pnpm --filter @mintel/image-processor build
|
||||||
RUN pnpm --filter image-service build
|
RUN pnpm --filter image-service build
|
||||||
# Generated locally for caching
|
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/image-service/node_modules ./apps/image-service/node_modules
|
COPY --from=build /app/apps/image-service/node_modules ./apps/image-service/node_modules
|
||||||
COPY --from=build /app/packages/image-processor/node_modules ./packages/image-processor/node_modules
|
COPY --from=build /app/packages/image-processor/node_modules ./packages/image-processor/node_modules
|
||||||
# Make sure directories exist to prevent COPY errors
|
RUN mkdir -p /app/apps/image-service/dist
|
||||||
RUN mkdir -p /app/packages/image-processor/models /app/apps/image-service/dist
|
|
||||||
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
|
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
|
||||||
COPY --from=build /app/apps/image-service/package.json ./apps/image-service/package.json
|
COPY --from=build /app/apps/image-service/package.json ./apps/image-service/package.json
|
||||||
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
|
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
|
||||||
COPY --from=build /app/packages/image-processor/package.json ./packages/image-processor/package.json
|
COPY --from=build /app/packages/image-processor/package.json ./packages/image-processor/package.json
|
||||||
COPY --from=build /app/packages/image-processor/models ./packages/image-processor/models
|
|
||||||
|
|
||||||
# Need runtime dependencies for canvas/sharp on Debian
|
|
||||||
RUN apt-get update && apt-get install -y libcairo2 libpango-1.0-0 libjpeg62-turbo libgif7 librsvg2-2 && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
WORKDIR /app/apps/image-service
|
WORKDIR /app/apps/image-service
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "image-service",
|
"name": "image-service",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { processImageWithSmartCrop } from "@mintel/image-processor";
|
import {
|
||||||
|
processImageWithSmartCrop,
|
||||||
|
parseImgproxyOptions,
|
||||||
|
mapUrl,
|
||||||
|
} from "@mintel/image-processor";
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
|
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
|
||||||
// Compatibility endpoint for old imgproxy calls (optional, but requested by some systems sometimes)
|
const { options, urlSafeB64 } = request.params as {
|
||||||
// For now, replacing logic in clients is preferred. So we just redirect or error.
|
options: string;
|
||||||
return reply
|
urlSafeB64: string;
|
||||||
.status(400)
|
};
|
||||||
.send({ error: "Legacy imgproxy API not supported. Use /process" });
|
|
||||||
|
// 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,
|
||||||
|
openRouterApiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
fastify.get("/process", async (request, reply) => {
|
||||||
const query = request.query as {
|
const query = request.query as {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -26,41 +83,14 @@ fastify.get("/process", async (request, reply) => {
|
|||||||
const width = parseInt(query.w || "800", 10);
|
const width = parseInt(query.w || "800", 10);
|
||||||
const height = parseInt(query.h || "600", 10);
|
const height = parseInt(query.h || "600", 10);
|
||||||
const quality = parseInt(query.q || "80", 10);
|
const quality = parseInt(query.q || "80", 10);
|
||||||
const format = (query.format || "webp") as "webp" | "jpeg" | "png" | "avif";
|
const format = (query.format || "webp") as any;
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return reply.status(400).send({ error: 'Parameter "url" is required' });
|
return reply.status(400).send({ error: 'Parameter "url" is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
|
||||||
const response = await fetch(url);
|
return handleProcessing(mappedUrl, { width, height, quality, format }, reply);
|
||||||
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 () => {
|
fastify.get("/health", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sample-website",
|
"name": "sample-website",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"require-in-the-middle": "^8.0.1"
|
"require-in-the-middle": "^8.0.1"
|
||||||
},
|
},
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "acquisition-manager",
|
"name": "acquisition-manager",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "acquisition",
|
"name": "acquisition",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"directus:extension": {
|
"directus:extension": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cloner",
|
"name": "@mintel/cloner",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cms-infra",
|
"name": "@mintel/cms-infra",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "company-manager",
|
"name": "company-manager",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/content-engine",
|
"name": "@mintel/content-engine",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "customer-manager",
|
"name": "customer-manager",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/directus-extension-toolkit",
|
"name": "@mintel/directus-extension-toolkit",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
|
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/eslint-config",
|
"name": "@mintel/eslint-config",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "feedback-commander",
|
"name": "feedback-commander",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gatekeeper",
|
"name": "@mintel/gatekeeper",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/husky-config",
|
"name": "@mintel/husky-config",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/image-processor",
|
"name": "@mintel/image-processor",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -18,9 +18,6 @@
|
|||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensorflow/tfjs-node": "^4.22.0",
|
|
||||||
"@vladmandic/face-api": "^1.7.13",
|
|
||||||
"canvas": "^2.11.2",
|
|
||||||
"sharp": "^0.33.2"
|
"sharp": "^0.33.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import * as https from "node:https";
|
|
||||||
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
"tiny_face_detector_model-weights_manifest.json",
|
|
||||||
"tiny_face_detector_model-shard1",
|
|
||||||
];
|
|
||||||
|
|
||||||
async function downloadModel(filename: string) {
|
|
||||||
const destPath = path.join(MODELS_DIR, filename);
|
|
||||||
if (fs.existsSync(destPath)) {
|
|
||||||
console.log(`Model ${filename} already exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
console.log(`Downloading ${filename}...`);
|
|
||||||
const file = fs.createWriteStream(destPath);
|
|
||||||
https
|
|
||||||
.get(BASE_URL + filename, (response) => {
|
|
||||||
response.pipe(file);
|
|
||||||
file.on("finish", () => {
|
|
||||||
file.close();
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.on("error", (err) => {
|
|
||||||
fs.unlinkSync(destPath);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!fs.existsSync(MODELS_DIR)) {
|
|
||||||
fs.mkdirSync(MODELS_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const model of models) {
|
|
||||||
await downloadModel(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("All models downloaded successfully!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,51 +1,150 @@
|
|||||||
import * as faceapi from "@vladmandic/face-api";
|
|
||||||
// Provide Canvas fallback for face-api in Node.js
|
|
||||||
import { Canvas, Image, ImageData } from "canvas";
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
// @ts-expect-error FaceAPI does not have type definitions for monkeyPatch
|
|
||||||
faceapi.env.monkeyPatch({ Canvas, Image, ImageData });
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Path to the downloaded models
|
|
||||||
const MODELS_PATH = path.join(__dirname, "..", "models");
|
|
||||||
|
|
||||||
let isModelsLoaded = false;
|
|
||||||
|
|
||||||
async function loadModels() {
|
|
||||||
if (isModelsLoaded) return;
|
|
||||||
await faceapi.nets.tinyFaceDetector.loadFromDisk(MODELS_PATH);
|
|
||||||
isModelsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
openRouterApiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => m.split(":"));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FaceDetection {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects faces using OpenRouter Vision API.
|
||||||
|
* Uses a small preview to save bandwidth and tokens.
|
||||||
|
*/
|
||||||
|
async function detectFacesWithCloud(
|
||||||
|
inputBuffer: Buffer,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<FaceDetection[]> {
|
||||||
|
try {
|
||||||
|
// Generate a small preview for vision API (max 512px)
|
||||||
|
const preview = await sharp(inputBuffer)
|
||||||
|
.resize(512, 512, { fit: "inside" })
|
||||||
|
.jpeg({ quality: 60 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const base64Image = preview.toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://mintel.me",
|
||||||
|
"X-Title": "Mintel Image Service",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "google/gemini-3-flash-preview", // Fast, cheap, and supports vision
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: 'Detect all human faces in this image. Return ONLY a JSON array of bounding boxes like: [{"x": 0.1, "y": 0.2, "width": 0.05, "height": 0.05}]. Coordinates must be normalized (0 to 1). If no faces, return [].',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenRouter API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
const content = data.choices[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!content) return [];
|
||||||
|
|
||||||
|
// The model might return directly or wrapped in a json field
|
||||||
|
const parsed = typeof content === "string" ? JSON.parse(content) : content;
|
||||||
|
const detections = (parsed.faces || parsed.detections || parsed) as any[];
|
||||||
|
|
||||||
|
if (!Array.isArray(detections)) return [];
|
||||||
|
|
||||||
|
return detections.map((d) => ({
|
||||||
|
x: d.x,
|
||||||
|
y: d.y,
|
||||||
|
width: d.width,
|
||||||
|
height: d.height,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cloud face detection failed:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processImageWithSmartCrop(
|
export async function processImageWithSmartCrop(
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
options: ProcessImageOptions,
|
options: ProcessImageOptions,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
await loadModels();
|
|
||||||
|
|
||||||
// Load image via Canvas for face-api
|
|
||||||
const img = new Image();
|
|
||||||
img.src = inputBuffer;
|
|
||||||
|
|
||||||
// Detect faces
|
|
||||||
const detections = await faceapi.detectAllFaces(
|
|
||||||
// @ts-expect-error FaceAPI does not have type definitions for monkeyPatch
|
|
||||||
img,
|
|
||||||
new faceapi.TinyFaceDetectorOptions(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sharpImage = sharp(inputBuffer);
|
const sharpImage = sharp(inputBuffer);
|
||||||
const metadata = await sharpImage.metadata();
|
const metadata = await sharpImage.metadata();
|
||||||
|
|
||||||
@@ -53,35 +152,36 @@ export async function processImageWithSmartCrop(
|
|||||||
throw new Error("Could not read image metadata");
|
throw new Error("Could not read image metadata");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detections = options.openRouterApiKey
|
||||||
|
? await detectFacesWithCloud(inputBuffer, options.openRouterApiKey)
|
||||||
|
: [];
|
||||||
|
|
||||||
// If faces are found, calculate the bounding box containing all faces
|
// If faces are found, calculate the bounding box containing all faces
|
||||||
if (detections.length > 0) {
|
if (detections.length > 0) {
|
||||||
|
// Map normalized coordinates back to pixels
|
||||||
|
const pixelDetections = detections.map((d) => ({
|
||||||
|
x: d.x * (metadata.width || 0),
|
||||||
|
y: d.y * (metadata.height || 0),
|
||||||
|
width: d.width * (metadata.width || 0),
|
||||||
|
height: d.height * (metadata.height || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
let minX = metadata.width;
|
let minX = metadata.width;
|
||||||
let minY = metadata.height;
|
let minY = metadata.height;
|
||||||
let maxX = 0;
|
let maxX = 0;
|
||||||
let maxY = 0;
|
let maxY = 0;
|
||||||
|
|
||||||
for (const det of detections) {
|
for (const det of pixelDetections) {
|
||||||
const { x, y, width, height } = det.box;
|
if (det.x < minX) minX = Math.max(0, det.x);
|
||||||
if (x < minX) minX = Math.max(0, x);
|
if (det.y < minY) minY = Math.max(0, det.y);
|
||||||
if (y < minY) minY = Math.max(0, y);
|
if (det.x + det.width > maxX)
|
||||||
if (x + width > maxX) maxX = Math.min(metadata.width, x + width);
|
maxX = Math.min(metadata.width, det.x + det.width);
|
||||||
if (y + height > maxY) maxY = Math.min(metadata.height, y + height);
|
if (det.y + det.height > maxY)
|
||||||
|
maxY = Math.min(metadata.height, det.y + det.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
const faceBoxWidth = maxX - minX;
|
const centerX = Math.floor(minX + (maxX - minX) / 2);
|
||||||
const faceBoxHeight = maxY - minY;
|
const centerY = Math.floor(minY + (maxY - minY) / 2);
|
||||||
|
|
||||||
// Calculate center of the faces
|
|
||||||
const centerX = Math.floor(minX + faceBoxWidth / 2);
|
|
||||||
const centerY = Math.floor(minY + faceBoxHeight / 2);
|
|
||||||
|
|
||||||
// Provide this as a focus point for sharp's extract or resize
|
|
||||||
// We can use sharp's resize with `position` focusing on crop options,
|
|
||||||
// or calculate an exact bounding box. However, extracting an exact bounding box
|
|
||||||
// and then resizing usually yields the best results when focusing on a specific coordinate.
|
|
||||||
|
|
||||||
// A simpler approach is to crop a rectangle with the target aspect ratio
|
|
||||||
// centered on the faces, then resize. Let's calculate the crop box.
|
|
||||||
|
|
||||||
const targetRatio = options.width / options.height;
|
const targetRatio = options.width / options.height;
|
||||||
const currentRatio = metadata.width / metadata.height;
|
const currentRatio = metadata.width / metadata.height;
|
||||||
@@ -90,18 +190,14 @@ export async function processImageWithSmartCrop(
|
|||||||
let cropHeight = metadata.height;
|
let cropHeight = metadata.height;
|
||||||
|
|
||||||
if (currentRatio > targetRatio) {
|
if (currentRatio > targetRatio) {
|
||||||
// Image is wider than target, calculate new width
|
|
||||||
cropWidth = Math.floor(metadata.height * targetRatio);
|
cropWidth = Math.floor(metadata.height * targetRatio);
|
||||||
} else {
|
} else {
|
||||||
// Image is taller than target, calculate new height
|
|
||||||
cropHeight = Math.floor(metadata.width / targetRatio);
|
cropHeight = Math.floor(metadata.width / targetRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to center the crop box around the faces
|
|
||||||
let cropX = Math.floor(centerX - cropWidth / 2);
|
let cropX = Math.floor(centerX - cropWidth / 2);
|
||||||
let cropY = Math.floor(centerY - cropHeight / 2);
|
let cropY = Math.floor(centerY - cropHeight / 2);
|
||||||
|
|
||||||
// Keep crop box within image bounds
|
|
||||||
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;
|
||||||
@@ -116,9 +212,7 @@ export async function processImageWithSmartCrop(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
fit: "cover",
|
fit: "cover",
|
||||||
position: detections.length > 0 ? "center" : "attention",
|
position: detections.length > 0 ? "center" : "attention",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/infra",
|
"name": "@mintel/infra",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/journaling",
|
"name": "@mintel/journaling",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/mail",
|
"name": "@mintel/mail",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/meme-generator",
|
"name": "@mintel/meme-generator",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-config",
|
"name": "@mintel/next-config",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-feedback",
|
"name": "@mintel/next-feedback",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-observability",
|
"name": "@mintel/next-observability",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-utils",
|
"name": "@mintel/next-utils",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/observability",
|
"name": "@mintel/observability",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/pdf",
|
"name": "@mintel/pdf",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "people-manager",
|
"name": "people-manager",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/thumbnail-generator",
|
"name": "@mintel/thumbnail-generator",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/tsconfig",
|
"name": "@mintel/tsconfig",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "unified-dashboard",
|
"name": "unified-dashboard",
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
"description": "Custom High-Fidelity Management for Directus",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.8.12",
|
"version": "1.8.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
656
pnpm-lock.yaml
generated
656
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user