This commit introduces face detection capabilities to the video platform, enabling automatic identification of faces in video thumbnails. It integrates face-api.js and sharp for image analysis, allowing for face-centered thumbnail crops and dynamic object-positioning. New API endpoints are added to process thumbnails individually and in batches. The database schema is updated to store face detection data, and the storage layer is modified to support these updates and cache face data. The project's dependencies are also updated to include necessary libraries for these new features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/xF0EUqR
258 lines
8.3 KiB
TypeScript
258 lines
8.3 KiB
TypeScript
import * as faceapi from 'face-api.js';
|
|
import * as canvas from 'canvas';
|
|
import sharp from 'sharp';
|
|
import path from 'path';
|
|
import { promises as fs } from 'fs';
|
|
|
|
// Setup face-api.js for Node.js environment
|
|
const { Canvas, Image, ImageData } = canvas;
|
|
// @ts-ignore
|
|
faceapi.env.monkeyPatch({ Canvas, Image, ImageData });
|
|
|
|
export interface FaceDetectionResult {
|
|
success: boolean;
|
|
faces_detected: number;
|
|
primary_face_confidence: number;
|
|
crop_info: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
original_dimensions: {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
processing_strategy: 'face_centered' | 'center_crop';
|
|
error?: string;
|
|
}
|
|
|
|
export class JavaScriptFaceDetectionService {
|
|
private modelsLoaded = false;
|
|
|
|
constructor() {
|
|
this.loadModels();
|
|
}
|
|
|
|
private async loadModels(): Promise<void> {
|
|
if (this.modelsLoaded) return;
|
|
|
|
try {
|
|
// Load face detection models from face-api.js
|
|
// Using smaller models for better performance
|
|
await faceapi.nets.tinyFaceDetector.loadFromDisk('./node_modules/face-api.js/weights');
|
|
await faceapi.nets.faceLandmark68TinyNet.loadFromDisk('./node_modules/face-api.js/weights');
|
|
|
|
this.modelsLoaded = true;
|
|
console.log('✅ Face detection models loaded successfully');
|
|
} catch (error) {
|
|
console.error('❌ Failed to load face detection models:', error);
|
|
// Fallback: continue without face detection
|
|
}
|
|
}
|
|
|
|
private async downloadImage(imageUrl: string): Promise<{ buffer: Buffer; width: number; height: number }> {
|
|
// Use Node.js built-in fetch (available from Node 18+)
|
|
const response = await fetch(imageUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download image: ${response.statusText}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const metadata = await sharp(buffer).metadata();
|
|
|
|
return {
|
|
buffer,
|
|
width: metadata.width || 0,
|
|
height: metadata.height || 0
|
|
};
|
|
}
|
|
|
|
private async detectFacesInImage(imageBuffer: Buffer): Promise<any[]> {
|
|
if (!this.modelsLoaded) {
|
|
await this.loadModels();
|
|
if (!this.modelsLoaded) {
|
|
return []; // Return empty array if models couldn't load
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Convert image buffer to canvas
|
|
const img = new Image();
|
|
img.src = imageBuffer;
|
|
|
|
// Wait for image to load
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = reject;
|
|
});
|
|
|
|
// Detect faces with tiny face detector for performance
|
|
const detections = await faceapi
|
|
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions())
|
|
.withFaceLandmarks(true);
|
|
|
|
return detections;
|
|
} catch (error) {
|
|
console.error('Error detecting faces:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private calculateFaceCenteredCrop(
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
faceBox: { x: number; y: number; width: number; height: number },
|
|
targetAspect: number = 9/16
|
|
): { x: number; y: number; width: number; height: number } {
|
|
const faceCenterX = faceBox.x + faceBox.width / 2;
|
|
const faceCenterY = faceBox.y + faceBox.height / 2;
|
|
|
|
let cropWidth: number, cropHeight: number, cropX: number, cropY: number;
|
|
|
|
if (imageWidth / imageHeight > targetAspect) {
|
|
// Image is wider than target aspect ratio
|
|
cropWidth = Math.floor(imageHeight * targetAspect);
|
|
cropHeight = imageHeight;
|
|
cropX = Math.max(0, Math.min(faceCenterX - cropWidth / 2, imageWidth - cropWidth));
|
|
cropY = 0;
|
|
} else {
|
|
// Image is taller than target aspect ratio
|
|
cropWidth = imageWidth;
|
|
cropHeight = Math.floor(imageWidth / targetAspect);
|
|
cropX = 0;
|
|
cropY = Math.max(0, Math.min(faceCenterY - cropHeight / 2, imageHeight - cropHeight));
|
|
}
|
|
|
|
return { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
|
|
}
|
|
|
|
private calculateCenterCrop(
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
targetAspect: number = 9/16
|
|
): { x: number; y: number; width: number; height: number } {
|
|
let cropWidth: number, cropHeight: number, cropX: number, cropY: number;
|
|
|
|
if (imageWidth / imageHeight > targetAspect) {
|
|
cropWidth = Math.floor(imageHeight * targetAspect);
|
|
cropHeight = imageHeight;
|
|
cropX = Math.floor((imageWidth - cropWidth) / 2);
|
|
cropY = 0;
|
|
} else {
|
|
cropWidth = imageWidth;
|
|
cropHeight = Math.floor(imageWidth / targetAspect);
|
|
cropX = 0;
|
|
cropY = Math.floor((imageHeight - cropHeight) / 2);
|
|
}
|
|
|
|
return { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
|
|
}
|
|
|
|
public async processThumbnail(thumbnailUrl: string): Promise<FaceDetectionResult> {
|
|
try {
|
|
console.log(`🔍 Processing thumbnail: ${thumbnailUrl}`);
|
|
|
|
// Download and analyze the image
|
|
const { buffer, width, height } = await this.downloadImage(thumbnailUrl);
|
|
|
|
if (width === 0 || height === 0) {
|
|
throw new Error('Invalid image dimensions');
|
|
}
|
|
|
|
// Detect faces in the image
|
|
const faces = await this.detectFacesInImage(buffer);
|
|
|
|
console.log(`👥 Found ${faces.length} faces in image`);
|
|
|
|
let cropInfo: { x: number; y: number; width: number; height: number };
|
|
let strategy: 'face_centered' | 'center_crop';
|
|
let confidence = 0;
|
|
|
|
if (faces.length > 0) {
|
|
// Use the first (most confident) face for centering
|
|
const primaryFace = faces[0];
|
|
const faceBox = primaryFace.detection.box;
|
|
confidence = primaryFace.detection.score;
|
|
|
|
cropInfo = this.calculateFaceCenteredCrop(width, height, faceBox);
|
|
strategy = 'face_centered';
|
|
|
|
console.log(`✅ Face-centered crop calculated with confidence: ${confidence.toFixed(2)}`);
|
|
} else {
|
|
// No faces detected, use center crop
|
|
cropInfo = this.calculateCenterCrop(width, height);
|
|
strategy = 'center_crop';
|
|
|
|
console.log(`📐 Center crop calculated (no faces detected)`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
faces_detected: faces.length,
|
|
primary_face_confidence: confidence,
|
|
crop_info: cropInfo,
|
|
original_dimensions: { width, height },
|
|
processing_strategy: strategy
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error processing thumbnail ${thumbnailUrl}:`, error);
|
|
return {
|
|
success: false,
|
|
faces_detected: 0,
|
|
primary_face_confidence: 0,
|
|
crop_info: { x: 0, y: 0, width: 0, height: 0 },
|
|
original_dimensions: { width: 0, height: 0 },
|
|
processing_strategy: 'center_crop',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
public calculateObjectPosition(
|
|
cropInfo: { x: number; y: number; width: number; height: number },
|
|
originalDimensions: { width: number; height: number }
|
|
): string {
|
|
if (!cropInfo || !originalDimensions.width || !originalDimensions.height) {
|
|
return 'center center';
|
|
}
|
|
|
|
// Calculate the percentage position of the crop center
|
|
const cropCenterX = cropInfo.x + cropInfo.width / 2;
|
|
const cropCenterY = cropInfo.y + cropInfo.height / 2;
|
|
|
|
const positionX = (cropCenterX / originalDimensions.width) * 100;
|
|
const positionY = (cropCenterY / originalDimensions.height) * 100;
|
|
|
|
return `${Math.max(0, Math.min(100, positionX)).toFixed(1)}% ${Math.max(0, Math.min(100, positionY)).toFixed(1)}%`;
|
|
}
|
|
|
|
public async getOptimizedThumbnailInfo(thumbnailUrl: string) {
|
|
try {
|
|
const result = await this.processThumbnail(thumbnailUrl);
|
|
|
|
return {
|
|
originalUrl: thumbnailUrl,
|
|
faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions),
|
|
facesDetected: result.faces_detected,
|
|
confidence: result.primary_face_confidence,
|
|
strategy: result.processing_strategy,
|
|
processedSuccessfully: result.success
|
|
};
|
|
} catch (error) {
|
|
console.error('Error optimizing thumbnail:', error);
|
|
return {
|
|
originalUrl: thumbnailUrl,
|
|
faceCenterPosition: 'center center',
|
|
facesDetected: 0,
|
|
confidence: 0,
|
|
strategy: 'center_crop' as const,
|
|
processedSuccessfully: false
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const jsFaceDetectionService = new JavaScriptFaceDetectionService(); |