feat(content-engine): enhance content pruning rule in orchestrator
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/observability": "workspace:*",
|
"@mintel/observability": "workspace:*",
|
||||||
"@mintel/next-observability": "workspace:*",
|
"@mintel/next-observability": "workspace:*",
|
||||||
|
"@mintel/image-processor": "workspace:*",
|
||||||
"@sentry/nextjs": "10.38.0",
|
"@sentry/nextjs": "10.38.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
@@ -33,4 +34,4 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
apps/sample-website/src/app/api/image/route.ts
Normal file
45
apps/sample-website/src/app/api/image/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { processImageWithSmartCrop } from '@mintel/image-processor';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
let width = parseInt(searchParams.get('w') || '800');
|
||||||
|
let height = parseInt(searchParams.get('h') || '600');
|
||||||
|
let 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);
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
models/tiny_face_detector_model-shard1
Normal file
1
models/tiny_face_detector_model-shard1
Normal file
@@ -0,0 +1 @@
|
|||||||
|
404: Not Found
|
||||||
30
models/tiny_face_detector_model-weights_manifest.json
Normal file
30
models/tiny_face_detector_model-weights_manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
32
packages/image-processor/package.json
Normal file
32
packages/image-processor/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/image-processor",
|
||||||
|
"version": "1.0.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 src/index.ts --format esm --dts --clean",
|
||||||
|
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||||
|
"lint": "eslint src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vladmandic/face-api": "^1.7.13",
|
||||||
|
"canvas": "^2.11.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/image-processor/scripts/download-models.ts
Normal file
48
packages/image-processor/scripts/download-models.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
|
||||||
|
const MODELS_DIR = path.join(process.cwd(), '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
packages/image-processor/src/index.ts
Normal file
1
packages/image-processor/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './processor.js';
|
||||||
139
packages/image-processor/src/processor.ts
Normal file
139
packages/image-processor/src/processor.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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 * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
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 {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
||||||
|
quality?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processImageWithSmartCrop(
|
||||||
|
inputBuffer: Buffer,
|
||||||
|
options: ProcessImageOptions
|
||||||
|
): 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-ignore
|
||||||
|
img,
|
||||||
|
new faceapi.TinyFaceDetectorOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharpImage = sharp(inputBuffer);
|
||||||
|
const metadata = await sharpImage.metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error('Could not read image metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If faces are found, calculate the bounding box containing all faces
|
||||||
|
if (detections.length > 0) {
|
||||||
|
let minX = metadata.width;
|
||||||
|
let minY = metadata.height;
|
||||||
|
let maxX = 0;
|
||||||
|
let maxY = 0;
|
||||||
|
|
||||||
|
for (const det of detections) {
|
||||||
|
const { x, y, width, height } = det.box;
|
||||||
|
if (x < minX) minX = Math.max(0, x);
|
||||||
|
if (y < minY) minY = Math.max(0, y);
|
||||||
|
if (x + width > maxX) maxX = Math.min(metadata.width, x + width);
|
||||||
|
if (y + height > maxY) maxY = Math.min(metadata.height, y + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceBoxWidth = maxX - minX;
|
||||||
|
const faceBoxHeight = maxY - minY;
|
||||||
|
|
||||||
|
// 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 currentRatio = metadata.width / metadata.height;
|
||||||
|
|
||||||
|
let cropWidth = metadata.width;
|
||||||
|
let cropHeight = metadata.height;
|
||||||
|
|
||||||
|
if (currentRatio > targetRatio) {
|
||||||
|
// Image is wider than target, calculate new width
|
||||||
|
cropWidth = Math.floor(metadata.height * targetRatio);
|
||||||
|
} else {
|
||||||
|
// Image is taller than target, calculate new height
|
||||||
|
cropHeight = Math.floor(metadata.width / targetRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to center the crop box around the faces
|
||||||
|
let cropX = Math.floor(centerX - cropWidth / 2);
|
||||||
|
let cropY = Math.floor(centerY - cropHeight / 2);
|
||||||
|
|
||||||
|
// Keep crop box within 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;
|
||||||
|
|
||||||
|
sharpImage.extract({
|
||||||
|
left: cropX,
|
||||||
|
top: cropY,
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, resize to the requested dimensions and format
|
||||||
|
let finalImage = sharpImage.resize(options.width, options.height, {
|
||||||
|
// If faces weren't found, default to entropy/attention based cropping as fallback
|
||||||
|
fit: 'cover',
|
||||||
|
position: detections.length > 0 ? 'center' : 'attention'
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
19
packages/image-processor/tsconfig.json
Normal file
19
packages/image-processor/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
626
pnpm-lock.yaml
generated
626
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user