mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Update emoji rendering to correctly handle long words and center text, and adjust the client-side preview to remove hashtags instead of emojis. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 1a295c79-6a41-4cf1-94cd-94aa3c4f96e6 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/JcM96XE
287 lines
9.0 KiB
TypeScript
287 lines
9.0 KiB
TypeScript
import sharp from "sharp";
|
|
import { randomUUID } from "crypto";
|
|
import path from "path";
|
|
import fs from "fs/promises";
|
|
import { createCanvas, loadImage, registerFont } from "canvas";
|
|
import { removeHashtags } from "@shared/emoji";
|
|
import { emojiRenderer } from "./emojiRenderer";
|
|
|
|
interface CropDimensions {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ProcessedImages {
|
|
facebookFeed: string;
|
|
instagramFeed: string;
|
|
instagramStory: string;
|
|
}
|
|
|
|
const CROP_FORMATS = {
|
|
facebookFeed: { width: 1200, height: 630 } as CropDimensions,
|
|
instagramFeed: { width: 1080, height: 1080 } as CropDimensions,
|
|
instagramStory: { width: 1080, height: 1920 } as CropDimensions,
|
|
};
|
|
|
|
// Register DejaVu Sans font for proper character rendering
|
|
try {
|
|
const fontPath = path.join(process.cwd(), "server", "fonts", "DejaVuSans.ttf");
|
|
registerFont(fontPath, { family: 'DejaVu Sans' });
|
|
console.log('✓ Font DejaVu Sans enregistrée avec succès');
|
|
} catch (error) {
|
|
console.error('Erreur lors de l\'enregistrement de la police:', error);
|
|
}
|
|
|
|
export class ImageProcessor {
|
|
private uploadsDir: string;
|
|
|
|
constructor() {
|
|
this.uploadsDir = path.join(process.cwd(), "uploads");
|
|
this.ensureUploadsDirExists();
|
|
}
|
|
|
|
private async ensureUploadsDirExists() {
|
|
try {
|
|
await fs.access(this.uploadsDir);
|
|
} catch {
|
|
await fs.mkdir(this.uploadsDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async processImage(buffer: Buffer, originalFilename: string): Promise<ProcessedImages> {
|
|
const fileExt = path.extname(originalFilename);
|
|
const baseId = randomUUID();
|
|
|
|
const processedImages: ProcessedImages = {
|
|
facebookFeed: "",
|
|
instagramFeed: "",
|
|
instagramStory: "",
|
|
};
|
|
|
|
// Process Facebook Feed (1200x630)
|
|
const fbFeedFilename = `${baseId}-fb-feed${fileExt}`;
|
|
const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename);
|
|
await sharp(buffer)
|
|
.resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, {
|
|
fit: "cover",
|
|
position: "center",
|
|
})
|
|
.toFile(fbFeedPath);
|
|
processedImages.facebookFeed = `/uploads/${fbFeedFilename}`;
|
|
|
|
// Process Instagram Feed (1080x1080)
|
|
const igFeedFilename = `${baseId}-ig-feed${fileExt}`;
|
|
const igFeedPath = path.join(this.uploadsDir, igFeedFilename);
|
|
await sharp(buffer)
|
|
.resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, {
|
|
fit: "cover",
|
|
position: "center",
|
|
})
|
|
.toFile(igFeedPath);
|
|
processedImages.instagramFeed = `/uploads/${igFeedFilename}`;
|
|
|
|
// Process Instagram Story (1080x1920)
|
|
const igStoryFilename = `${baseId}-ig-story${fileExt}`;
|
|
const igStoryPath = path.join(this.uploadsDir, igStoryFilename);
|
|
await sharp(buffer)
|
|
.resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, {
|
|
fit: "cover",
|
|
position: "center",
|
|
})
|
|
.toFile(igStoryPath);
|
|
processedImages.instagramStory = `/uploads/${igStoryFilename}`;
|
|
|
|
return processedImages;
|
|
}
|
|
|
|
async saveOriginalFile(buffer: Buffer, filename: string): Promise<string> {
|
|
const uniqueFilename = `${randomUUID()}-${filename}`;
|
|
const filePath = path.join(this.uploadsDir, uniqueFilename);
|
|
await fs.writeFile(filePath, buffer);
|
|
return `/uploads/${uniqueFilename}`;
|
|
}
|
|
|
|
async deleteFile(filePath: string): Promise<void> {
|
|
try {
|
|
const fullPath = path.join(process.cwd(), filePath);
|
|
await fs.unlink(fullPath);
|
|
} catch (error) {
|
|
console.error("Error deleting file:", error);
|
|
}
|
|
}
|
|
|
|
async addTextToStoryImage(imageUrl: string, text: string): Promise<Buffer> {
|
|
const STORY_WIDTH = 1080;
|
|
const STORY_HEIGHT = 1920;
|
|
const TEXT_BOX_HEIGHT_RATIO = 0.25;
|
|
const MIN_FONT_SIZE = 12;
|
|
const MAX_FONT_SIZE = 72;
|
|
const PADDING = 40;
|
|
const LINE_HEIGHT_MULTIPLIER = 1.3;
|
|
|
|
// Remove hashtags from text while keeping emojis
|
|
const cleanText = removeHashtags(text);
|
|
|
|
const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT);
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const image = await loadImage(imageUrl);
|
|
|
|
const imgWidth = image.width;
|
|
const imgHeight = image.height;
|
|
const targetRatio = STORY_WIDTH / STORY_HEIGHT;
|
|
const imgRatio = imgWidth / imgHeight;
|
|
|
|
let drawWidth, drawHeight, offsetX, offsetY;
|
|
|
|
if (imgRatio > targetRatio) {
|
|
drawHeight = STORY_HEIGHT;
|
|
drawWidth = imgWidth * (STORY_HEIGHT / imgHeight);
|
|
offsetX = (STORY_WIDTH - drawWidth) / 2;
|
|
offsetY = 0;
|
|
} else {
|
|
drawWidth = STORY_WIDTH;
|
|
drawHeight = imgHeight * (STORY_WIDTH / imgWidth);
|
|
offsetX = 0;
|
|
offsetY = (STORY_HEIGHT - drawHeight) / 2;
|
|
}
|
|
|
|
ctx.fillStyle = 'black';
|
|
ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT);
|
|
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
|
|
|
const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO;
|
|
const textBoxY = STORY_HEIGHT - textBoxHeight;
|
|
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight);
|
|
|
|
const maxTextWidth = STORY_WIDTH - (PADDING * 2);
|
|
const maxTextHeight = textBoxHeight - (PADDING * 2);
|
|
|
|
let fontSize = MAX_FONT_SIZE;
|
|
let lines: string[] = [];
|
|
let totalTextHeight = 0;
|
|
|
|
while (fontSize >= MIN_FONT_SIZE) {
|
|
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
|
|
|
|
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
|
|
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
|
|
totalTextHeight = lines.length * lineHeight;
|
|
|
|
if (totalTextHeight <= maxTextHeight) {
|
|
break;
|
|
}
|
|
|
|
fontSize -= 2;
|
|
}
|
|
|
|
if (fontSize < MIN_FONT_SIZE) {
|
|
fontSize = MIN_FONT_SIZE;
|
|
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
|
|
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
|
|
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
|
|
totalTextHeight = lines.length * lineHeight;
|
|
|
|
const maxLines = Math.floor(maxTextHeight / lineHeight);
|
|
if (lines.length > maxLines) {
|
|
lines = lines.slice(0, maxLines);
|
|
if (lines[maxLines - 1]) {
|
|
lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...';
|
|
}
|
|
totalTextHeight = maxLines * lineHeight;
|
|
}
|
|
}
|
|
|
|
ctx.fillStyle = 'white';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
|
|
const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2;
|
|
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const line = lines[index];
|
|
const y = startY + (index * lineHeight);
|
|
const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize);
|
|
const x = (STORY_WIDTH - lineWidth) / 2; // Center the line
|
|
await emojiRenderer.drawText(line, ctx, x, y, fontSize);
|
|
}
|
|
|
|
return canvas.toBuffer('image/png');
|
|
}
|
|
|
|
private wrapText(ctx: any, text: string, maxWidth: number): string[] {
|
|
const words = text.split(' ');
|
|
const lines: string[] = [];
|
|
let currentLine = '';
|
|
|
|
for (const word of words) {
|
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
const metrics = ctx.measureText(testLine);
|
|
|
|
if (metrics.width > maxWidth && currentLine) {
|
|
// Current line would exceed width, push it and start new line
|
|
lines.push(currentLine);
|
|
currentLine = word;
|
|
|
|
// Check if the word itself is too long for a single line
|
|
const wordMetrics = ctx.measureText(word);
|
|
if (wordMetrics.width > maxWidth) {
|
|
// Word is too long, need to break it into chunks
|
|
const chunks = this.breakLongWord(ctx, word, maxWidth);
|
|
for (let i = 0; i < chunks.length - 1; i++) {
|
|
lines.push(chunks[i]);
|
|
}
|
|
currentLine = chunks[chunks.length - 1];
|
|
}
|
|
} else if (metrics.width > maxWidth && !currentLine) {
|
|
// First word is too long, need to break it
|
|
const chunks = this.breakLongWord(ctx, word, maxWidth);
|
|
for (let i = 0; i < chunks.length - 1; i++) {
|
|
lines.push(chunks[i]);
|
|
}
|
|
currentLine = chunks[chunks.length - 1];
|
|
} else {
|
|
currentLine = testLine;
|
|
}
|
|
}
|
|
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Breaks a long word into chunks that fit within maxWidth
|
|
*/
|
|
private breakLongWord(ctx: any, word: string, maxWidth: number): string[] {
|
|
const chunks: string[] = [];
|
|
let currentChunk = '';
|
|
|
|
for (let i = 0; i < word.length; i++) {
|
|
const char = word[i];
|
|
const testChunk = currentChunk + char;
|
|
const metrics = ctx.measureText(testChunk);
|
|
|
|
if (metrics.width > maxWidth && currentChunk) {
|
|
chunks.push(currentChunk);
|
|
currentChunk = char;
|
|
} else {
|
|
currentChunk = testChunk;
|
|
}
|
|
}
|
|
|
|
if (currentChunk) {
|
|
chunks.push(currentChunk);
|
|
}
|
|
|
|
return chunks.length > 0 ? chunks : [word];
|
|
}
|
|
}
|
|
|
|
export const imageProcessor = new ImageProcessor();
|